Repository: argoproj/argo
Pull request #3233 (2020-06-15 15:33:05)

build: Migrate to Github Actions
Checklist:

* [ ] Either (a) I've created an [enhancement proposal](https://github.com/argoproj/argo/issues/new/choose) and discussed it with the community, (b) this is a bug fix, or (c) this is a chore.
* [ ] The title of the PR is (a) [conventional](https://www.conventionalcommits.org/en/v1.0.0/), (b) states what changed, and (c) suffixes the related issues number. E.g. `"fix(controller): Updates such and such. Fixes #1234"`.  
* [ ] I've signed the CLA.
* [ ] I have written unit and/or e2e tests for my change. PRs without these are unlikely to be merged.
* [ ] My builds are green. Try syncing with master if they are not. 
* [ ] My organization is added to [USERS.md](https://github.com/argoproj/argo/blob/master/USERS.md).



Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 9398d0249..90fc4e238 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,18 +2,6 @@ version: 2.1
 orbs:
   sonarcloud: sonarsource/sonarcloud@1.0.1
 commands:
-  maybe_skip_job:
-    steps:
-      - run:
-          name: Maybe skip job if not relevant
-          command: ./hack/maybe-skip-job.sh $CIRCLE_JOB
-  install_kustomize:
-    steps:
-      - run:
-          name: Install Kustomize
-          command: |
-            cd /usr/local/bin && curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | sudo bash
-            kustomize version
   restore_go_mod:
     steps:
       - restore_cache:
@@ -53,117 +41,18 @@ commands:
           keys:
             - go-v7-{{ .Branch }}-{{ .Environment.CIRCLE_JOB }}
             - go-v7-master-{{ .Environment.CIRCLE_JOB }}
-  e2e:
-    steps:
-      - checkout
-      - maybe_skip_job
-      - install_kustomize
-      - run:
-          name: Install and start K3S v1.0.1
-          command: curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.0.1 INSTALL_K3S_EXEC=--docker K3S_KUBECONFIG_MODE=644 sh -
-          background: true
-      - restore_go_cache
-      - install_golang
-      - run:
-          name: Preemptively pull images
-          command: make pull-build-images test-images
-          background: true
-      - restore_go_mod
-      - run: go mod download
-      - run:
-          name: Create KUBECONFIG
-          # Copy kubeconfig file, and add a fake user for "argo --token xxx" testing
-          command: |
-            mkdir -p ~/.kube
-            until stat /etc/rancher/k3s/k3s.yaml ; do sleep 10s ; done
-            cat /etc/rancher/k3s/k3s.yaml | sed "s/127.0.0.1/$(hostname)/g" > ~/.kube/config
-            echo "- name: fake_token_user" >> ~/.kube/config
-            echo "  user:" >> ~/.kube/config
-            echo "    token: xxxxxx" >> ~/.kube/config
-      - run:
-          name: Start Argo
-          command: |
-            echo '127.0.0.1 dex'      | sudo tee -a /etc/hosts
-            echo '127.0.0.1 minio'    | sudo tee -a /etc/hosts
-            echo '127.0.0.1 postgres' | sudo tee -a /etc/hosts
-            echo '127.0.0.1 mysql'    | sudo tee -a /etc/hosts
-            mkdir dist
-            KUBECONFIG=~/.kube/config make start PROFILE=$PROFILE E2E_EXECUTOR=docker DEV_IMAGE=true
-            # CircleCI will kill spawned processes, so lets block for 20m
-            sleep 20m
-          background: true
-      - run:
-          name: Wait for Argo Server to be ready
-          command: make wait
-      - run:
-          name: Run tests
-          command: |
-            trap 'make test-results/junit.xml' EXIT
-            make smoke $SUITE
-      - store_test_results:
-          path: test-results
-          when: always
-      - store_artifacts:
-          name: Store test results
-          path: test-results
-          destination: .
-          when: always
-      - store_artifacts:
-          name: Store logs
-          path: /tmp/log/argo-e2e
-          destination: .
-          when: always
-      - save_go_mod
-      - save_go_cache
 jobs:
-  codegen:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    steps:
-      - checkout
-      - maybe_skip_job
-      - install_kustomize
-      - restore_go_cache
-      - install_golang
-      - run:
-          name: Install protoc
-          command: |
-            set -eux -o pipefail
-            PROTOC_ZIP=protoc-3.11.1-linux-x86_64.zip
-            curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.11.1/$PROTOC_ZIP
-            sudo unzip -o $PROTOC_ZIP -d /usr/local bin/protoc
-            sudo unzip -o $PROTOC_ZIP -d /usr/local 'include/*'
-            sudo chmod +x /usr/local/bin/protoc
-            sudo find /usr/local/include -type f | xargs sudo chmod a+r
-            sudo find /usr/local/include -type d | xargs sudo chmod a+rx
-            rm -f $PROTOC_ZIP
-            ls /usr/local/include/google/protobuf/
-      - run: go mod download
-      - run: make codegen
-      - run: make manifests
-      - run: make lint
-      - run: git diff --exit-code
-      - save_go_cache
-      - save_go_mod
-  test:
+  sonar:
     working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
     machine:
       image: ubuntu-1604:201903-01
     steps:
       - checkout
-      - maybe_skip_job
-      - install_kustomize
       - restore_go_cache
       - install_golang
       - restore_go_mod
-      - run: go mod download
-      - run:
-          name: Run tests
-          command: |
-            trap 'make test-results/junit.xml' EXIT
-            make test
       - run: |
+          mkdir dist
           cp sonar-project.properties dist/
           if [ "$(echo "$CIRCLE_BRANCH" | grep 'master\|release-')" = '' ]; then
             echo "sonar.pullrequest.branch=$CIRCLE_BRANCH" >> sonar-project.properties
@@ -180,81 +69,9 @@ jobs:
           when: always
       - save_go_cache
       - save_go_mod
-  e2e-cron:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    environment:
-      PROFILE: mysql
-      SUITE: test-e2e-cron
-    steps:
-      - e2e
-  e2e-minimal:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    environment:
-      PROFILE: minimal
-      SUITE: test-e2e
-    steps:
-      - e2e
-  e2e-postgres:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    environment:
-      PROFILE: postgres
-      SUITE: test-e2e
-    steps:
-      - e2e
-  e2e-mysql:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    environment:
-      PROFILE: mysql
-      SUITE: test-e2e
-    steps:
-      - e2e
-  docker-build:
-    working_directory: /home/circleci/.go_workspace/src/github.com/argoproj/argo
-    machine:
-      image: ubuntu-1604:201903-01
-    steps:
-      - checkout
-      - maybe_skip_job
-      - run: DOCKER_BUILDKIT=1 docker build .
-  ui:
-    docker:
-      - image: circleci/node:14.0.0
-    steps:
-      - checkout
-      - maybe_skip_job
-      - restore_cache:
-          name: Restore Yarn cache
-          keys:
-            - yarn-packages-v3-{{ checksum "ui/yarn.lock" }}
-      - run:
-          name: Download dependencies
-          command: yarn --cwd ui install
-      # - run: yarn test
-      - run: yarn --cwd ui build
-      - run: yarn --cwd ui lint
-      - run: git diff --exit-code
-      - save_cache:
-          name: Save Yarn cache
-          key: yarn-packages-v3-{{ checksum "ui/yarn.lock" }}
-          paths: [~/.cache/yarn, node_modules]
-          when: always
+
 workflows:
   version: 2
-  argo-workflow:
+  CI:
     jobs:
-      - test
-      - ui
-      - codegen
-      - e2e-cron
-      - e2e-mysql
-      - e2e-minimal
-      - e2e-postgres
-      - docker-build
+      - sonar
diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml
new file mode 100644
index 000000000..f5c1e6e8d
--- /dev/null
+++ b/.github/workflows/ci-build.yaml
@@ -0,0 +1,177 @@
+name: CI
+on: 
+  push:
+    branches:
+      - 'master' 
+      - 'release-*'
+      - '!release-2.8'
+  pull_request:
+    branches:
+      - 'master'
+
+jobs:
+  tests:
+    name: Tests
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        test: ["test", "test-e2e", "test-e2e-cron"]
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+      - name: Specify branch
+        run: git checkout -b pull/$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')
+      - name: Restore go build cache
+        uses: actions/cache@v1
+        with:
+          path: ~/.cache/go-build
+          key: ${{ runner.os }}-go-build-v1-${{ github.run_id }}
+      - name: Setup Golang
+        uses: actions/setup-go@v1
+        with:
+          go-version: '1.13.12'
+      - name: Add bins to PATH
+        run: |
+          echo "::add-path::/home/runner/go/bin"
+          echo "::add-path::/usr/local/bin"
+      - name: Install Kustomize
+        run: |
+            cd /usr/local/bin && curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | sudo bash
+            kustomize version
+      - name: Install and start K3S v1.0.1
+        if: ${{ matrix.test != 'test' }}
+        run: curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.0.1 INSTALL_K3S_EXEC=--docker K3S_KUBECONFIG_MODE=644 sh - &
+      - name: Pre-pull images
+        if: ${{ matrix.test != 'test' }}
+        env:
+          GOPATH: /home/runner/go
+        run: make pull-build-images test-images &
+      - name: Download all Go modules
+        run: |
+          go mod download
+      - name: Create Kubeconfig
+        if: ${{ matrix.test != 'test' }}
+        run: |
+          mkdir -p ~/.kube
+          until stat /etc/rancher/k3s/k3s.yaml ; do sleep 10s ; done
+          cat /etc/rancher/k3s/k3s.yaml | sed "s/127.0.0.1/$(hostname)/g" > ~/.kube/config
+          echo "- name: fake_token_user" >> ~/.kube/config
+          echo "  user:" >> ~/.kube/config
+          echo "    token: xxxxxx" >> ~/.kube/config
+      - name: Start Argo
+        if: ${{ matrix.test != 'test' }}
+        env:
+          GOPATH: /home/runner/go
+          PROFILE: mysql
+        run: |
+          echo '127.0.0.1 dex'      | sudo tee -a /etc/hosts
+          echo '127.0.0.1 minio'    | sudo tee -a /etc/hosts
+          echo '127.0.0.1 postgres' | sudo tee -a /etc/hosts
+          echo '127.0.0.1 mysql'    | sudo tee -a /etc/hosts
+          mkdir dist
+          KUBECONFIG=~/.kube/config make start PROFILE=$PROFILE E2E_EXECUTOR=docker DEV_IMAGE=true &
+      - name: Wait for Argo Server to be ready
+        if: ${{ matrix.test != 'test' }}
+        env:
+          GOPATH: /home/runner/go
+        run: make wait
+      - name: Run tests
+        env:
+          GOPATH: /home/runner/go
+        run: |
+          trap 'make test-results/junit.xml' EXIT
+          make ${{ matrix.test }}
+      - name: Upload test results
+        if: failure()
+        uses: actions/upload-artifact@v1
+        with:
+          name: ${{ matrix.test }}
+          path: test-results
+
+  codegen:
+    name: Codegen
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+      - name: Specify branch
+        run: git checkout -b pull/$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')
+      - name: Restore go build cache
+        uses: actions/cache@v1
+        with:
+          path: ~/.cache/go-build
+          key: ${{ runner.os }}-go-build-v1-${{ github.run_id }}
+      - name: Setup Golang
+        uses: actions/setup-go@v1
+        with:
+          go-version: '1.13.12'
+      - name: Add bins to PATH
+        run: |
+          echo "::add-path::/home/runner/go/bin"
+          echo "::add-path::/usr/local/bin"
+      - name: Download all Go modules
+        run: |
+          go mod download
+      - name: Install Kustomize
+        run: |
+            cd /usr/local/bin && curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"  | sudo bash
+            kustomize version
+      - name: Install protoc
+        run: |
+          set -eux -o pipefail
+          PROTOC_ZIP=protoc-3.11.1-linux-x86_64.zip
+          curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.11.1/$PROTOC_ZIP
+          sudo unzip -o $PROTOC_ZIP -d /usr/local bin/protoc
+          sudo unzip -o $PROTOC_ZIP -d /usr/local 'include/*'
+          sudo chmod +x /usr/local/bin/protoc
+          sudo find /usr/local/include -type f | xargs sudo chmod a+r
+          sudo find /usr/local/include -type d | xargs sudo chmod a+rx
+          rm -f $PROTOC_ZIP
+          ls /usr/local/include/google/protobuf/
+      - name: Make codegen
+        env:
+          GOPATH: /home/runner/go
+        run: |
+          make codegen
+      - name: Make manifests
+        env:
+          GOPATH: /home/runner/go
+        run: |
+          make manifests
+      - name: Make lint
+        env:
+          GOPATH: /home/runner/go
+        run: |
+          make lint
+      - name: Ensure nothing changed
+        run: git diff --exit-code
+
+  ui:
+    name: UI
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.event.pull_request.head.sha }}
+      - name: Specify branch
+        run: git checkout -b pull/$(echo $GITHUB_REF | awk 'BEGIN { FS = "/" } ; { print $3 }')
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '14'
+      - name: Restore node dependency cache
+        uses: actions/cache@v1
+        with:
+          path: ui/node_modules
+          key: ${{ runner.os }}-node-dep-v1-${{ hashFiles('**/yarn.lock') }}
+      - name: Install and Lint
+        run: |
+          yarn --cwd ui install
+          yarn --cwd ui lint
+      - name: Ensure nothing changed
+        run: git diff --exit-code
diff --git a/cmd/argo/commands/watch.go b/cmd/argo/commands/watch.go
index c78acfca5..3a12f7120 100644
--- a/cmd/argo/commands/watch.go
+++ b/cmd/argo/commands/watch.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"time"
 
 	"github.com/argoproj/pkg/errors"
 	log "github.com/sirupsen/logrus"
@@ -33,7 +34,6 @@ func NewWatchCommand() *cobra.Command {
 				os.Exit(1)
 			}
 			watchWorkflow(args[0], getArgs)
-
 		},
 	}
 	command.Flags().StringVar(&getArgs.status, "status", "", "Filter by status (Pending, Running, Succeeded, Skipped, Failed, Error)")
@@ -56,19 +56,35 @@ func watchWorkflow(wfName string, getArgs getFlags) {
 	}
 	stream, err := serviceClient.WatchWorkflows(ctx, req)
 	errors.CheckError(err)
-	for {
-		event, err := stream.Recv()
-		if err == io.EOF {
-			log.Debug("Re-establishing workflow watch")
-			stream, err = serviceClient.WatchWorkflows(ctx, req)
+
+	wfChan := make(chan *wfv1.Workflow)
+	go func() {
+		for {
+			event, err := stream.Recv()
+			if err == io.EOF {
+				log.Debug("Re-establishing workflow watch")
+				stream, err = serviceClient.WatchWorkflows(ctx, req)
+				errors.CheckError(err)
+			}
 			errors.CheckError(err)
-			continue
+			wfChan <- event.Object
 		}
-		errors.CheckError(err)
-		wf := event.Object
-		if wf == nil {
-			break
+	}()
+
+	var wf *wfv1.Workflow
+	ticker := time.NewTicker(time.Second)
+	for {
+		select {
+		case newWf := <-wfChan:
+			// If we get a new event, update our workflow
+			if newWf == nil {
+				return
+			}
+			wf = newWf
+		case <-ticker.C:
+			// If we don't, refresh the workflow screen every second
 		}
+
 		printWorkflowStatus(wf, getArgs)
 		if !wf.Status.FinishedAt.IsZero() {
 			return
@@ -77,6 +93,9 @@ func watchWorkflow(wfName string, getArgs getFlags) {
 }
 
 func printWorkflowStatus(wf *wfv1.Workflow, getArgs getFlags) {
+	if wf == nil {
+		return
+	}
 	err := packer.DecompressWorkflow(wf)
 	errors.CheckError(err)
 	print("\033[H\033[2J")
diff --git a/config/config.go b/config/config.go
index 94deeefce..f55ad6ce6 100644
--- a/config/config.go
+++ b/config/config.go
@@ -14,6 +14,9 @@ type Config struct {
 	// SSO in settings for single-sign on
 	SSO sso.Config `json:"sso,omitempty"`
 
+	// NodeEvents configures how node events are omitted
+	NodeEvents NodeEvents `json:"nodeEvents,omitempty"`
+
 	// ExecutorImage is the image name of the executor to use when running pods
 	// DEPRECATED: use --executor-image flag to workflow-controller instead
 	ExecutorImage string `json:"executorImage,omitempty"`
diff --git a/config/node_events.go b/config/node_events.go
new file mode 100644
index 000000000..51c76e263
--- /dev/null
+++ b/config/node_events.go
@@ -0,0 +1,9 @@
+package config
+
+type NodeEvents struct {
+	Enabled *bool `json:"enabled,omitempty"`
+}
+
+func (e NodeEvents) IsEnabled() bool {
+	return e.Enabled == nil || *e.Enabled
+}
diff --git a/docs/workflow-controller-configmap.yaml b/docs/workflow-controller-configmap.yaml
index 837188ee6..118224c21 100644
--- a/docs/workflow-controller-configmap.yaml
+++ b/docs/workflow-controller-configmap.yaml
@@ -18,6 +18,14 @@ data:
     # (available since Argo v2.3)
     parallelism: 10
 
+    # Whether or not to emit events on node completion. These can take a up a lot of space in
+    # k8s (typically etcd) resulting in errors when trying to create new events:
+    # "Unable to create audit event: etcdserver: mvcc: database space exceeded"
+    # This config item allows you to disable this.
+    # (since v2.9)
+    nodeEvents:
+      enabled: true
+
     # uncomment flowing lines if workflow controller runs in a different k8s cluster with the 
     # workflow workloads, or needs to communicate with the k8s apiserver using an out-of-cluster
     # kubeconfig secret
diff --git a/manifests/cluster-install/workflow-controller-rbac/workflow-controller-clusterrole.yaml b/manifests/cluster-install/workflow-controller-rbac/workflow-controller-clusterrole.yaml
index e5e78d239..169168378 100644
--- a/manifests/cluster-install/workflow-controller-rbac/workflow-controller-clusterrole.yaml
+++ b/manifests/cluster-install/workflow-controller-rbac/workflow-controller-clusterrole.yaml
@@ -80,6 +80,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
     - "policy"
   resources:
diff --git a/manifests/install.yaml b/manifests/install.yaml
index 1dcf2702b..36811d84c 100644
--- a/manifests/install.yaml
+++ b/manifests/install.yaml
@@ -250,6 +250,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
   - policy
   resources:
diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml
index 47f2b70be..30a7eb1b3 100644
--- a/manifests/namespace-install.yaml
+++ b/manifests/namespace-install.yaml
@@ -163,6 +163,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
   - policy
   resources:
diff --git a/manifests/namespace-install/workflow-controller-rbac/workflow-controller-role.yaml b/manifests/namespace-install/workflow-controller-rbac/workflow-controller-role.yaml
index 9677d76a2..5556ab739 100644
--- a/manifests/namespace-install/workflow-controller-rbac/workflow-controller-role.yaml
+++ b/manifests/namespace-install/workflow-controller-rbac/workflow-controller-role.yaml
@@ -84,6 +84,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
     - "policy"
   resources:
diff --git a/manifests/quick-start-minimal.yaml b/manifests/quick-start-minimal.yaml
index 75ad7b2e8..8f41294d5 100644
--- a/manifests/quick-start-minimal.yaml
+++ b/manifests/quick-start-minimal.yaml
@@ -163,6 +163,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
   - policy
   resources:
diff --git a/manifests/quick-start-mysql.yaml b/manifests/quick-start-mysql.yaml
index 01e887fbd..5e53202d4 100644
--- a/manifests/quick-start-mysql.yaml
+++ b/manifests/quick-start-mysql.yaml
@@ -163,6 +163,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
   - policy
   resources:
diff --git a/manifests/quick-start-postgres.yaml b/manifests/quick-start-postgres.yaml
index 7e7bef89f..7390dd522 100644
--- a/manifests/quick-start-postgres.yaml
+++ b/manifests/quick-start-postgres.yaml
@@ -163,6 +163,7 @@ rules:
   - events
   verbs:
   - create
+  - patch
 - apiGroups:
   - policy
   resources:
diff --git a/pkg/apis/workflow/v1alpha1/workflow_types.go b/pkg/apis/workflow/v1alpha1/workflow_types.go
index 7ed053254..fc63dc354 100644
--- a/pkg/apis/workflow/v1alpha1/workflow_types.go
+++ b/pkg/apis/workflow/v1alpha1/workflow_types.go
@@ -1139,6 +1139,8 @@ const (
 	ConditionTypeCompleted ConditionType = "Completed"
 	// ConditionTypeSpecWarning is a warning on the current application spec
 	ConditionTypeSpecWarning ConditionType = "SpecWarning"
+	// ConditionTypeSpecWarning is an error on the current application spec
+	ConditionTypeSpecError ConditionType = "SpecError"
 	// ConditionTypeMetricsError is an error during metric emission
 	ConditionTypeMetricsError ConditionType = "MetricsError"
 )
diff --git a/server/workflow/workflow_server.go b/server/workflow/workflow_server.go
index b6a4fefa0..e8121ee9c 100644
--- a/server/workflow/workflow_server.go
+++ b/server/workflow/workflow_server.go
@@ -142,11 +142,13 @@ func (s *workflowServer) WatchWorkflows(req *workflowpkg.WatchWorkflowsRequest,
 	if req.ListOptions != nil {
 		opts = req.ListOptions
 		wfName := argoutil.RecoverWorkflowNameFromSelectorString(opts.FieldSelector)
-		wf, err := s.getWorkflow(wfClient, req.Namespace, wfName, metav1.GetOptions{})
-		if err != nil {
-			return err
+		if len(wfName) > 0 {
+			wf, err := s.getWorkflow(wfClient, req.Namespace, wfName, metav1.GetOptions{})
+			if err != nil {
+				return err
+			}
+			opts.FieldSelector = argoutil.GenerateFieldSelectorFromWorkflowName(wf.Name)
 		}
-		opts.FieldSelector = argoutil.GenerateFieldSelectorFromWorkflowName(wf.Name)
 	}
 	s.instanceIDService.With(opts)
 	wfIf := wfClient.ArgoprojV1alpha1().Workflows(req.Namespace)
diff --git a/ui/src/app/shared/conditions-panel.tsx b/ui/src/app/shared/conditions-panel.tsx
index dbbe45cd0..ce129e32d 100644
--- a/ui/src/app/shared/conditions-panel.tsx
+++ b/ui/src/app/shared/conditions-panel.tsx
@@ -6,7 +6,7 @@ interface Props {
 }
 
 const WarningConditions: ConditionType[] = ['SpecWarning'];
-const ErrorConditions: ConditionType[] = ['MetricsError', 'SubmissionError'];
+const ErrorConditions: ConditionType[] = ['MetricsError', 'SubmissionError', 'SpecError'];
 
 export function hasWarningConditionBadge(conditions: Condition[]): boolean {
     if (conditions.length === 0) {
diff --git a/ui/src/app/shared/services/workflows-service.ts b/ui/src/app/shared/services/workflows-service.ts
index 0931aa0b7..9bf7e5486 100644
--- a/ui/src/app/shared/services/workflows-service.ts
+++ b/ui/src/app/shared/services/workflows-service.ts
@@ -23,7 +23,17 @@ export class WorkflowsService {
         if (pagination.limit) {
             params.push(`listOptions.limit=${pagination.limit}`);
         }
-        params.push(`fields=metadata,items.metadata.uid,items.metadata.name,items.metadata.namespace,items.status.phase,items.status.finishedAt,items.status.startedAt`);
+        const fields = [
+            'metadata',
+            'items.metadata.uid',
+            'items.metadata.name',
+            'items.metadata.namespace',
+            'items.metadata.labels',
+            'items.status.phase',
+            'items.status.finishedAt',
+            'items.status.startedAt'
+        ];
+        params.push(`fields=${fields.join(',')}`);
         return requests.get(`api/v1/workflows/${namespace}?${params.join('&')}`).then(res => res.body as WorkflowList);
     }
 
@@ -58,7 +68,8 @@ export class WorkflowsService {
             'result.object.status.finishedAt',
             'result.object.status.phase',
             'result.object.status.startedAt',
-            'result.type'
+            'result.type',
+            'result.object.metadata.labels'
         ];
         params.push(`fields=${fields.join(',')}`);
         const url = `api/v1/workflow-events/${filter.namespace || ''}?${params.join('&')}`;
diff --git a/ui/src/models/workflows.ts b/ui/src/models/workflows.ts
index d3ff8d28b..0290ce52a 100644
--- a/ui/src/models/workflows.ts
+++ b/ui/src/models/workflows.ts
@@ -839,7 +839,7 @@ export interface Condition {
     message: string;
 }
 
-export type ConditionType = 'Completed' | 'SpecWarning' | 'MetricsError' | 'SubmissionError';
+export type ConditionType = 'Completed' | 'SpecWarning' | 'MetricsError' | 'SubmissionError' | 'SpecError';
 export type ConditionStatus = 'True' | 'False' | 'Unknown;';
 
 /**
diff --git a/util/argo/audit_logger.go b/util/argo/audit_logger.go
deleted file mode 100644
index 141349fe0..000000000
--- a/util/argo/audit_logger.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package argo
-
-import (
-	"fmt"
-	"time"
-
-	log "github.com/sirupsen/logrus"
-	corev1 "k8s.io/api/core/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/client-go/kubernetes"
-
-	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
-	"github.com/argoproj/argo/workflow/common"
-)
-
-type AuditLogger struct {
-	kIf       kubernetes.Interface
-	component string
-	ns        string
-}
-
-type EventInfo struct {
-	Type   string
-	Reason string
-}
-
-const (
-	EventReasonWorkflowRunning       = "WorkflowRunning"
-	EventReasonWorkflowSucceeded     = "WorkflowSucceeded"
-	EventReasonWorkflowFailed        = "WorkflowFailed"
-	EventReasonWorkflowTimedOut      = "WorkflowTimedOut"
-	EventReasonWorkflowNodeSucceeded = "WorkflowNodeSucceeded"
-	EventReasonWorkflowNodeFailed    = "WorkflowNodeFailed"
-	EventReasonWorkflowNodeError     = "WorkflowNodeError"
-)
-
-func (l *AuditLogger) logEvent(workflow *wfv1.Workflow, info EventInfo, message string, annotations map[string]string) {
-	logCtx := log.WithFields(log.Fields{
-		"type":     info.Type,
-		"reason":   info.Reason,
-		"workflow": workflow.Name,
-	})
-	t := metav1.Time{Time: time.Now()}
-	event := corev1.Event{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:        fmt.Sprintf("%v.%x", workflow.Name, t.UnixNano()),
-			Annotations: annotations,
-		},
-		Source: corev1.EventSource{
-			Component: l.component,
-		},
-		InvolvedObject: corev1.ObjectReference{
-			Kind:            wfv1.WorkflowSchemaGroupVersionKind.Kind,
-			APIVersion:      wfv1.SchemeGroupVersion.String(),
-			Name:            workflow.Name,
-			Namespace:       workflow.Namespace,
-			ResourceVersion: workflow.ResourceVersion,
-			UID:             workflow.UID,
-		},
-		FirstTimestamp: t,
-		LastTimestamp:  t,
-		Count:          1,
-		Message:        message,
-		Type:           info.Type,
-		Reason:         info.Reason,
-	}
-	logCtx.WithField("event", event).Debug()
-	_, err := l.kIf.CoreV1().Events(workflow.Namespace).Create(&event)
-	if err != nil {
-		logCtx.Errorf("Unable to create audit event: %v", err)
-		return
-	}
-}
-
-func (l *AuditLogger) logWorkflowEvent(workflow *wfv1.Workflow, info EventInfo, message string, annotations map[string]string) {
-	l.logEvent(workflow, info, message, annotations)
-}
-
-func (l *AuditLogger) LogWorkflowEvent(workflow *wfv1.Workflow, info EventInfo, message string) {
-	l.logWorkflowEvent(workflow, info, message, nil)
-}
-
-func (l *AuditLogger) LogWorkflowNodeEvent(workflow *wfv1.Workflow, node *wfv1.NodeStatus) {
-	l.logWorkflowEvent(
-		workflow,
-		EventInfo{Type: eventType(node.Phase), Reason: nodePhaseReason(node.Phase)},
-		nodeMessage(node),
-		map[string]string{
-			common.AnnotationKeyNodeType: string(node.Type),
-			common.AnnotationKeyNodeName: node.Name,
-		})
-}
-
-func eventType(phase wfv1.NodePhase) string {
-	return map[wfv1.NodePhase]string{
-		wfv1.NodeError:     corev1.EventTypeWarning,
-		wfv1.NodeFailed:    corev1.EventTypeWarning,
-		wfv1.NodeSucceeded: corev1.EventTypeNormal,
-	}[phase]
-}
-func nodePhaseReason(phase wfv1.NodePhase) string {
-	return map[wfv1.NodePhase]string{
-		wfv1.NodeError:     EventReasonWorkflowNodeError,
-		wfv1.NodeFailed:    EventReasonWorkflowNodeFailed,
-		wfv1.NodeSucceeded: EventReasonWorkflowNodeSucceeded,
-	}[phase]
-}
-
-func nodeMessage(node *wfv1.NodeStatus) string {
-	message := fmt.Sprintf("%v node %s", node.Phase, node.Name)
-	if node.Message != "" {
-		message = message + ": " + node.Message
-	}
-	return message
-}
-
-func NewAuditLogger(ns string, kIf kubernetes.Interface, component string) *AuditLogger {
-	return &AuditLogger{
-		ns:        ns,
-		kIf:       kIf,
-		component: component,
-	}
-}
diff --git a/workflow/controller/controller.go b/workflow/controller/controller.go
index 0824cefba..e6d49b67d 100644
--- a/workflow/controller/controller.go
+++ b/workflow/controller/controller.go
@@ -22,8 +22,11 @@ import (
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/kubernetes/scheme"
+	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 	"upper.io/db.v3/lib/sqlbuilder"
 
@@ -81,6 +84,7 @@ type WorkflowController struct {
 	hydrator              hydrator.Interface
 	wfArchive             sqldb.WorkflowArchive
 	metrics               metrics.Metrics
+	eventRecorder         record.EventRecorder
 }
 
 const (
@@ -102,6 +106,10 @@ func NewWorkflowController(
 	containerRuntimeExecutor,
 	configMap string,
 ) *WorkflowController {
+	eventBroadcaster := record.NewBroadcaster()
+	eventBroadcaster.StartLogging(log.Debugf)
+	eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: kubeclientset.CoreV1().Events(namespace)})
+	eventRecorder := eventBroadcaster.NewRecorder(scheme.Scheme, apiv1.EventSource{Component: "workflow-controller"})
 	wfc := WorkflowController{
 		restConfig:                 restConfig,
 		kubeclientset:              kubeclientset,
@@ -116,6 +124,7 @@ func NewWorkflowController(
 		configController:           config.NewController(namespace, configMap, kubeclientset),
 		completedPods:              make(chan string, 512),
 		gcPods:                     make(chan string, 512),
+		eventRecorder:              eventRecorder,
 	}
 	wfc.throttler = NewThrottler(0, wfc.wfQueue)
 	wfc.UpdateConfig()
diff --git a/workflow/controller/operator.go b/workflow/controller/operator.go
index 3cc105f26..2cf2611ee 100644
--- a/workflow/controller/operator.go
+++ b/workflow/controller/operator.go
@@ -25,6 +25,7 @@ import (
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/record"
 	"k8s.io/utils/pointer"
 	"sigs.k8s.io/yaml"
 
@@ -34,7 +35,6 @@ import (
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	"github.com/argoproj/argo/pkg/client/clientset/versioned/typed/workflow/v1alpha1"
 	"github.com/argoproj/argo/util"
-	"github.com/argoproj/argo/util/argo"
 	"github.com/argoproj/argo/util/resource"
 	"github.com/argoproj/argo/util/retry"
 	"github.com/argoproj/argo/workflow/common"
@@ -81,8 +81,7 @@ type wfOperationCtx struct {
 	// workflowDeadline is the deadline which the workflow is expected to complete before we
 	// terminate the workflow.
 	workflowDeadline *time.Time
-	// auditLogger is the argo audit logger
-	auditLogger *argo.AuditLogger
+	eventRecorder    record.EventRecorder
 	// preExecutionNodePhases contains the phases of all the nodes before the current operation. Necessary to infer
 	// changes in phase for metric emission
 	preExecutionNodePhases map[string]wfv1.NodePhase
@@ -132,7 +131,7 @@ func newWorkflowOperationCtx(wf *wfv1.Workflow, wfc *WorkflowController) *wfOper
 		completedPods:          make(map[string]bool),
 		succeededPods:          make(map[string]bool),
 		deadline:               time.Now().UTC().Add(maxOperationTime),
-		auditLogger:            argo.NewAuditLogger(wf.ObjectMeta.Namespace, wfc.kubeclientset, wf.ObjectMeta.Name),
+		eventRecorder:          wfc.eventRecorder,
 		preExecutionNodePhases: make(map[string]wfv1.NodePhase),
 	}
 
@@ -194,10 +193,10 @@ func (woc *wfOperationCtx) operate() {
 		if err != nil {
 			msg := fmt.Sprintf("Unable to create PDB resource for workflow, %s error: %s", woc.wf.Name, err)
 			woc.markWorkflowFailed(msg)
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 			return
 		}
-		woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeNormal, Reason: argo.EventReasonWorkflowRunning}, "Workflow Running")
+		woc.eventRecorder.Event(woc.wf, apiv1.EventTypeNormal, "WorkflowRunning", "Workflow Running")
 		validateOpts := validate.ValidateOpts{ContainerRuntimeExecutor: woc.controller.GetContainerRuntimeExecutor()}
 		wftmplGetter := templateresolution.WrapWorkflowTemplateInterface(woc.controller.wfclientset.ArgoprojV1alpha1().WorkflowTemplates(woc.wf.Namespace))
 		cwftmplGetter := templateresolution.WrapClusterWorkflowTemplateInterface(woc.controller.wfclientset.ArgoprojV1alpha1().ClusterWorkflowTemplates())
@@ -207,7 +206,7 @@ func (woc *wfOperationCtx) operate() {
 		if err != nil {
 			msg := fmt.Sprintf("invalid spec: %s", err.Error())
 			woc.markWorkflowFailed(msg)
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 			return
 		}
 		// If we received conditions during validation (such as SpecWarnings), add them to the Workflow object
@@ -232,7 +231,7 @@ func (woc *wfOperationCtx) operate() {
 		}
 		if err != nil {
 			woc.log.Errorf("%s error: %+v", woc.wf.ObjectMeta.Name, err)
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowTimedOut}, "Workflow timed out")
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowTimedOut", "Workflow timed out")
 			// TODO: we need to re-add to the workqueue, but should happen in caller
 			return
 		}
@@ -265,7 +264,7 @@ func (woc *wfOperationCtx) operate() {
 			msg := fmt.Sprintf("Failed to load artifact repository configMap: %+v", err)
 			woc.log.Errorf(msg)
 			woc.markWorkflowError(err, true)
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 			return
 		}
 	}
@@ -283,7 +282,7 @@ func (woc *wfOperationCtx) operate() {
 		msg := fmt.Sprintf("%s pvc create error: %+v", woc.wf.ObjectMeta.Name, err)
 		woc.log.Errorf(msg)
 		woc.markWorkflowError(err, true)
-		woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+		woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 		return
 	}
 
@@ -294,9 +293,9 @@ func (woc *wfOperationCtx) operate() {
 		woc.log.Errorf(msg)
 		switch err {
 		case ErrDeadlineExceeded:
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowTimedOut}, msg)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowTimedOut", msg)
 		default:
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 		}
 		return
 	}
@@ -359,7 +358,7 @@ func (woc *wfOperationCtx) operate() {
 		// markCompletion so that we can retry PVC deletion (TODO: use workqueue.ReAdd())
 		// This error phase may be cleared if a subsequent delete attempt is successful.
 		woc.markWorkflowError(err, false)
-		woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, msg)
+		woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", msg)
 		return
 	}
 
@@ -379,17 +378,17 @@ func (woc *wfOperationCtx) operate() {
 			// if main workflow succeeded, but the exit node was unsuccessful
 			// the workflow is now considered unsuccessful.
 			woc.markWorkflowPhase(onExitNode.Phase, true, onExitNode.Message)
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, onExitNode.Message)
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", onExitNode.Message)
 		} else {
 			woc.markWorkflowSuccess()
-			woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeNormal, Reason: argo.EventReasonWorkflowSucceeded}, "Workflow completed")
+			woc.eventRecorder.Event(woc.wf, apiv1.EventTypeNormal, "WorkflowSucceeded", "Workflow completed")
 		}
 	case wfv1.NodeFailed:
 		woc.markWorkflowFailed(workflowMessage)
-		woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, workflowMessage)
+		woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", workflowMessage)
 	case wfv1.NodeError:
 		woc.markWorkflowPhase(wfv1.NodeError, true, workflowMessage)
-		woc.auditLogger.LogWorkflowEvent(woc.wf, argo.EventInfo{Type: apiv1.EventTypeWarning, Reason: argo.EventReasonWorkflowFailed}, workflowMessage)
+		woc.eventRecorder.Event(woc.wf, apiv1.EventTypeWarning, "WorkflowFailed", workflowMessage)
 	default:
 		// NOTE: we should never make it here because if the the node was 'Running'
 		// we should have returned earlier.
@@ -1738,7 +1737,27 @@ func (woc *wfOperationCtx) markNodePhase(nodeName string, phase wfv1.NodePhase,
 }
 
 func (woc *wfOperationCtx) onNodeComplete(node *wfv1.NodeStatus) {
-	woc.auditLogger.LogWorkflowNodeEvent(woc.wf, node)
+	if !woc.controller.Config.NodeEvents.IsEnabled() {
+		return
+	}
+	message := fmt.Sprintf("%v node %s", node.Phase, node.Name)
+	if node.Message != "" {
+		message = message + ": " + node.Message
+	}
+	eventType := apiv1.EventTypeWarning
+	if node.Phase == wfv1.NodeSucceeded {
+		eventType = apiv1.EventTypeNormal
+	}
+	woc.eventRecorder.AnnotatedEventf(
+		woc.wf,
+		map[string]string{
+			common.AnnotationKeyNodeType: string(node.Type),
+			common.AnnotationKeyNodeName: node.Name,
+		},
+		eventType,
+		fmt.Sprintf("WorkflowNode%s", node.Phase),
+		message,
+	)
 }
 
 // markNodeError is a convenience method to mark a node with an error and set the message from the error
diff --git a/workflow/cron/controller.go b/workflow/cron/controller.go
index f7439c160..19999203d 100644
--- a/workflow/cron/controller.go
+++ b/workflow/cron/controller.go
@@ -2,6 +2,7 @@ package cron
 
 import (
 	"context"
+	"fmt"
 	"sync"
 	"time"
 
@@ -126,7 +127,7 @@ func (cc *Controller) processNextCronItem() bool {
 
 	obj, exists, err := cc.cronWfInformer.Informer().GetIndexer().GetByKey(key.(string))
 	if err != nil {
-		log.Errorf("Failed to get CronWorkflow '%s' from informer index: %+v", key, err)
+		log.WithError(err).Error(fmt.Sprintf("Failed to get CronWorkflow '%s' from informer index", key))
 		return true
 	}
 	cc.nameEntryIDMapLock.Lock()
@@ -146,15 +147,17 @@ func (cc *Controller) processNextCronItem() bool {
 		return true
 	}
 
-	cronWorkflowOperationCtx, err := newCronWfOperationCtx(cronWf, cc.wfClientset, cc.wfLister, cc.metrics)
+	cronWorkflowOperationCtx := newCronWfOperationCtx(cronWf, cc.wfClientset, cc.wfLister, cc.metrics)
+
+	err = cronWorkflowOperationCtx.validateCronWorkflow()
 	if err != nil {
-		log.Error(err)
+		log.WithError(err).Error("invalid cron workflow")
 		return true
 	}
 
 	err = cronWorkflowOperationCtx.runOutstandingWorkflows()
 	if err != nil {
-		log.Errorf("could not run outstanding Workflow: %s", err)
+		log.WithError(err).Error("could not run outstanding Workflow")
 		return true
 	}
 
@@ -171,7 +174,7 @@ func (cc *Controller) processNextCronItem() bool {
 
 	entryId, err := cc.cron.AddJob(cronSchedule, cronWorkflowOperationCtx)
 	if err != nil {
-		log.Errorf("could not schedule CronWorkflow: %s", err)
+		log.WithError(err).Error("could not schedule CronWorkflow")
 		return true
 	}
 	cc.nameEntryIDMap[key.(string)] = entryId
@@ -195,7 +198,7 @@ func (cc *Controller) processNextWorkflowItem() bool {
 
 	obj, wfExists, err := cc.wfInformer.GetIndexer().GetByKey(key.(string))
 	if err != nil {
-		log.Errorf("Failed to get Workflow '%s' from informer index: %+v", key, err)
+		log.WithError(err).Error(fmt.Sprintf("Failed to get Workflow '%s' from informer index", key))
 		return true
 	}
 
diff --git a/workflow/cron/operator.go b/workflow/cron/operator.go
index 07ba5e7ea..1c53562b0 100644
--- a/workflow/cron/operator.go
+++ b/workflow/cron/operator.go
@@ -17,7 +17,9 @@ import (
 	typed "github.com/argoproj/argo/pkg/client/clientset/versioned/typed/workflow/v1alpha1"
 	"github.com/argoproj/argo/workflow/common"
 	"github.com/argoproj/argo/workflow/metrics"
+	"github.com/argoproj/argo/workflow/templateresolution"
 	"github.com/argoproj/argo/workflow/util"
+	"github.com/argoproj/argo/workflow/validate"
 )
 
 type cronWfOperationCtx struct {
@@ -32,7 +34,7 @@ type cronWfOperationCtx struct {
 	metrics     *metrics.Metrics
 }
 
-func newCronWfOperationCtx(cronWorkflow *v1alpha1.CronWorkflow, wfClientset versioned.Interface, wfLister util.WorkflowLister, metrics *metrics.Metrics) (*cronWfOperationCtx, error) {
+func newCronWfOperationCtx(cronWorkflow *v1alpha1.CronWorkflow, wfClientset versioned.Interface, wfLister util.WorkflowLister, metrics *metrics.Metrics) *cronWfOperationCtx {
 	return &cronWfOperationCtx{
 		name:        cronWorkflow.ObjectMeta.Name,
 		cronWf:      cronWorkflow,
@@ -45,21 +47,26 @@ func newCronWfOperationCtx(cronWorkflow *v1alpha1.CronWorkflow, wfClientset vers
 			"namespace": cronWorkflow.ObjectMeta.Namespace,
 		}),
 		metrics: metrics,
-	}, nil
+	}
 }
 
 func (woc *cronWfOperationCtx) Run() {
 	woc.log.Infof("Running %s", woc.name)
 
-	err := woc.reconcileDeletedWfs()
+	err := woc.validateCronWorkflow()
+	if err != nil {
+		return
+	}
+
+	err = woc.reconcileDeletedWfs()
 	if err != nil {
-		woc.reportCronWorkflowError(fmt.Sprintf("Could not remove deleted Workflow: %s", err))
+		woc.reportCronWorkflowError(v1alpha1.ConditionTypeSubmissionError, fmt.Sprintf("Could not remove deleted Workflow: %s", err))
 		return
 	}
 
 	proceed, err := woc.enforceRuntimePolicy()
 	if err != nil {
-		woc.reportCronWorkflowError(fmt.Sprintf("Concurrency policy error: %s", err))
+		woc.reportCronWorkflowError(v1alpha1.ConditionTypeSubmissionError, fmt.Sprintf("Concurrency policy error: %s", err))
 		return
 	} else if !proceed {
 		return
@@ -69,7 +76,7 @@ func (woc *cronWfOperationCtx) Run() {
 
 	runWf, err := util.SubmitWorkflow(woc.wfClient, woc.wfClientset, woc.cronWf.Namespace, wf, &v1alpha1.SubmitOpts{})
 	if err != nil {
-		woc.reportCronWorkflowError(fmt.Sprintf("Failed to submit Workflow: %s", err))
+		woc.reportCronWorkflowError(v1alpha1.ConditionTypeSubmissionError, fmt.Sprintf("Failed to submit Workflow: %s", err))
 		return
 	}
 
@@ -79,6 +86,19 @@ func (woc *cronWfOperationCtx) Run() {
 	woc.persistUpdate()
 }
 
+func (woc *cronWfOperationCtx) validateCronWorkflow() error {
+	wftmplGetter := templateresolution.WrapWorkflowTemplateInterface(woc.wfClientset.ArgoprojV1alpha1().WorkflowTemplates(woc.cronWf.Namespace))
+	cwftmplGetter := templateresolution.WrapClusterWorkflowTemplateInterface(woc.wfClientset.ArgoprojV1alpha1().ClusterWorkflowTemplates())
+	err := validate.ValidateCronWorkflow(wftmplGetter, cwftmplGetter, woc.cronWf)
+	if err != nil {
+		woc.reportCronWorkflowError(v1alpha1.ConditionTypeSpecError, fmt.Sprint(err))
+	} else {
+		woc.cronWf.Status.Conditions.RemoveCondition(v1alpha1.ConditionTypeSpecError)
+		woc.persistUpdate()
+	}
+	return err
+}
+
 func getWorkflowObjectReference(wf *v1alpha1.Workflow, runWf *v1alpha1.Workflow) corev1.ObjectReference {
 	// This is a bit of a hack. Ideally we'd use ref.GetReference, but for some reason the `runWf` object is coming back
 	// without `Kind` and `APIVersion` set (even though it it set on `wf`). To fix this, we hard code those values.
@@ -306,10 +326,10 @@ func (woc *cronWfOperationCtx) deleteOldestWorkflows(jobList []v1alpha1.Workflow
 	return nil
 }
 
-func (woc *cronWfOperationCtx) reportCronWorkflowError(errString string) {
-	woc.log.Errorf(errString)
+func (woc *cronWfOperationCtx) reportCronWorkflowError(conditionType v1alpha1.ConditionType, errString string) {
+	woc.log.Errorf("%s: %s", conditionType, errString)
 	woc.cronWf.Status.Conditions.UpsertCondition(v1alpha1.Condition{
-		Type:    v1alpha1.ConditionTypeSubmissionError,
+		Type:    conditionType,
 		Message: errString,
 		Status:  v1.ConditionTrue,
 	})


Test patch:
diff --git a/config/node_events_test.go b/config/node_events_test.go
new file mode 100644
index 000000000..394e343a2
--- /dev/null
+++ b/config/node_events_test.go
@@ -0,0 +1,14 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"k8s.io/utils/pointer"
+)
+
+func TestNodeEvents_IsEnabled(t *testing.T) {
+	assert.True(t, NodeEvents{}.IsEnabled())
+	assert.False(t, NodeEvents{Enabled: pointer.BoolPtr(false)}.IsEnabled())
+	assert.True(t, NodeEvents{Enabled: pointer.BoolPtr(true)}.IsEnabled())
+}
\ No newline at end of file
diff --git a/test/e2e/fixtures/then.go b/test/e2e/fixtures/then.go
index 91362fb9e..f87273e36 100644
--- a/test/e2e/fixtures/then.go
+++ b/test/e2e/fixtures/then.go
@@ -2,9 +2,9 @@ package fixtures
 
 import (
 	"testing"
+	"time"
 
 	log "github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/assert"
 	apiv1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
@@ -85,36 +85,34 @@ func (t *Then) ExpectWorkflowList(listOptions metav1.ListOptions, block func(t *
 	return t
 }
 
-func (t *Then) expectAuditEvents(block func(*testing.T, []apiv1.Event)) *Then {
-	eventList, err := t.kubeClient.CoreV1().Events(Namespace).List(metav1.ListOptions{})
+func (t *Then) ExpectAuditEvents(blocks ...func(*testing.T, apiv1.Event)) *Then {
+	eventList, err := t.kubeClient.CoreV1().Events(Namespace).Watch(metav1.ListOptions{})
 	if err != nil {
 		t.t.Fatal(err)
 	}
-	var events []apiv1.Event
-	for _, e := range eventList.Items {
-		if e.Namespace == Namespace && e.InvolvedObject.Kind == workflow.WorkflowKind {
-			events = append(events, e)
+	ticker := time.NewTicker(5 * time.Second)
+	defer ticker.Stop()
+	for len(blocks) > 0 {
+		select {
+		case <-ticker.C:
+			t.t.Fatal("timeout waiting for events")
+		case event := <-eventList.ResultChan():
+			e, ok := event.Object.(*apiv1.Event)
+			if !ok {
+				t.t.Fatal("event is not an event")
+			}
+			if e.InvolvedObject.Name == t.workflowName && e.Namespace == Namespace && e.InvolvedObject.Kind == workflow.WorkflowKind {
+				blocks[0](t.t, *e)
+				blocks = blocks[1:]
+				if t.t.Failed() {
+					t.t.FailNow()
+				}
+			}
 		}
 	}
-	log.WithFields(log.Fields{"event": events}).Debug("Events")
-	block(t.t, events)
-	if t.t.Failed() {
-		t.t.FailNow()
-	}
 	return t
 }
 
-func (t *Then) ExpectAuditEvent(f func(apiv1.Event) bool) *Then {
-	return t.expectAuditEvents(func(t *testing.T, events []apiv1.Event) {
-		for _, item := range events {
-			if f(item) {
-				return
-			}
-		}
-		assert.Fail(t, "did not see expected event")
-	})
-}
-
 func (t *Then) RunCli(args []string, block func(t *testing.T, output string, err error)) *Then {
 	output, err := runCli("../../dist/argo", append([]string{"-n", Namespace}, args...)...)
 	block(t.t, output, err)
diff --git a/test/e2e/functional_test.go b/test/e2e/functional_test.go
index 25b69119b..a68a3c91e 100644
--- a/test/e2e/functional_test.go
+++ b/test/e2e/functional_test.go
@@ -2,7 +2,6 @@ package e2e
 
 import (
 	"regexp"
-	"strings"
 	"testing"
 	"time"
 
@@ -11,10 +10,8 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
-	"github.com/argoproj/argo/pkg/apis/workflow"
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	"github.com/argoproj/argo/test/e2e/fixtures"
-	"github.com/argoproj/argo/util/argo"
 )
 
 type FunctionalSuite struct {
@@ -187,20 +184,23 @@ func (s *FunctionalSuite) TestEventOnNodeFail() {
 		Workflow("@expectedfailures/failed-step-event.yaml").
 		When().
 		SubmitWorkflow().
-		WaitForWorkflow(30 * time.Second).
+		WaitForWorkflow(30*time.Second).
 		Then().
-		ExpectAuditEvent(func(e corev1.Event) bool {
-			return strings.HasPrefix(e.InvolvedObject.Name, "failed-step-event-") &&
-				e.Reason == argo.EventReasonWorkflowFailed &&
-				e.Message == "failed with exit code 1"
-		}).
-		ExpectAuditEvent(func(e corev1.Event) bool {
-			return e.InvolvedObject.Kind == workflow.WorkflowKind &&
-				e.Reason == argo.EventReasonWorkflowNodeFailed &&
-				strings.HasPrefix(e.Message, "Failed node failed-step-event-") &&
-				e.Annotations["workflows.argoproj.io/node-type"] == "Pod" &&
-				strings.Contains(e.Annotations["workflows.argoproj.io/node-name"], "failed-step-event-")
-		})
+		ExpectAuditEvents(
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowRunning", e.Reason)
+			},
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, e.Reason, "WorkflowNodeFailed")
+				assert.Contains(t, e.Message, "Failed node failed-step-event-")
+				assert.Equal(t, e.Annotations["workflows.argoproj.io/node-type"], "Pod")
+				assert.Contains(t, e.Annotations["workflows.argoproj.io/node-name"], "failed-step-event-")
+			},
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowFailed", e.Reason)
+				assert.Equal(t, "failed with exit code 1", e.Message)
+			},
+		)
 }
 
 func (s *FunctionalSuite) TestEventOnWorkflowSuccess() {
@@ -209,20 +209,23 @@ func (s *FunctionalSuite) TestEventOnWorkflowSuccess() {
 		Workflow("@functional/success-event.yaml").
 		When().
 		SubmitWorkflow().
-		WaitForWorkflow(60 * time.Second).
+		WaitForWorkflow(60*time.Second).
 		Then().
-		ExpectAuditEvent(func(e corev1.Event) bool {
-			return strings.HasPrefix(e.InvolvedObject.Name, "success-event-") &&
-				e.Reason == argo.EventReasonWorkflowSucceeded &&
-				e.Message == "Workflow completed"
-		}).
-		ExpectAuditEvent(func(e corev1.Event) bool {
-			return e.InvolvedObject.Kind == workflow.WorkflowKind &&
-				e.Reason == argo.EventReasonWorkflowNodeSucceeded &&
-				strings.HasPrefix(e.Message, "Succeeded node success-event-") &&
-				e.Annotations["workflows.argoproj.io/node-type"] == "Pod" &&
-				strings.Contains(e.Annotations["workflows.argoproj.io/node-name"], "success-event-")
-		})
+		ExpectAuditEvents(
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowRunning", e.Reason)
+			},
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowNodeSucceeded", e.Reason)
+				assert.Contains(t, e.Message, "Succeeded node success-event-")
+				assert.Equal(t, "Pod", e.Annotations["workflows.argoproj.io/node-type"])
+				assert.Contains(t, e.Annotations["workflows.argoproj.io/node-name"], "success-event-")
+			},
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowSucceeded", e.Reason)
+				assert.Equal(t, "Workflow completed", e.Message)
+			},
+		)
 }
 
 func (s *FunctionalSuite) TestEventOnPVCFail() {
@@ -231,13 +234,17 @@ func (s *FunctionalSuite) TestEventOnPVCFail() {
 		Workflow("@expectedfailures/volumes-pvc-fail-event.yaml").
 		When().
 		SubmitWorkflow().
-		WaitForWorkflow(120 * time.Second).
+		WaitForWorkflow(120*time.Second).
 		Then().
-		ExpectAuditEvent(func(e corev1.Event) bool {
-			return strings.HasPrefix(e.InvolvedObject.Name, "volumes-pvc-fail-event-") &&
-				e.Reason == argo.EventReasonWorkflowFailed &&
-				strings.Contains(e.Message, "pvc create error")
-		})
+		ExpectAuditEvents(
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowRunning", e.Reason)
+			},
+			func(t *testing.T, e corev1.Event) {
+				assert.Equal(t, "WorkflowFailed", e.Reason)
+				assert.Contains(t, e.Message, "pvc create error")
+			},
+		)
 }
 
 func (s *FunctionalSuite) TestArtifactRepositoryRef() {
diff --git a/util/argo/audit_logger_test.go b/util/argo/audit_logger_test.go
deleted file mode 100644
index 6ad7ec1ef..000000000
--- a/util/argo/audit_logger_test.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package argo
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	corev1 "k8s.io/api/core/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/client-go/kubernetes/fake"
-
-	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
-)
-
-func TestAuditLogger_logEvent(t *testing.T) {
-	kube := fake.NewSimpleClientset()
-	l := AuditLogger{kIf: kube, component: "my-component"}
-	l.LogWorkflowNodeEvent(
-		&wfv1.Workflow{
-			ObjectMeta: metav1.ObjectMeta{Namespace: "my-ns", Name: "my-wf", UID: "my-uid", ResourceVersion: "1234"},
-		},
-		&wfv1.NodeStatus{Type: wfv1.NodeTypePod, Phase: wfv1.NodeSucceeded, Name: "my-node", Message: "my message"},
-	)
-	list, err := kube.CoreV1().Events("my-ns").List(metav1.ListOptions{})
-	if assert.NoError(t, err) && assert.Len(t, list.Items, 1) {
-		e := list.Items[0]
-		assert.NotEmpty(t, e.Name)
-		assert.Len(t, e.Annotations, 2)
-		assert.Equal(t, "my-node", e.Annotations["workflows.argoproj.io/node-name"])
-		assert.Equal(t, "Pod", e.Annotations["workflows.argoproj.io/node-type"])
-		assert.Equal(t, "my-component", e.Source.Component)
-		assert.Equal(t, corev1.ObjectReference{
-			Kind:            "Workflow",
-			Namespace:       "my-ns",
-			Name:            "my-wf",
-			UID:             "my-uid",
-			APIVersion:      "argoproj.io/v1alpha1",
-			ResourceVersion: "1234",
-		}, e.InvolvedObject)
-		assert.NotEmpty(t, e.FirstTimestamp)
-		assert.NotEmpty(t, e.LastTimestamp)
-		assert.Equal(t, int32(1), e.Count)
-		assert.Equal(t, "Succeeded node my-node: my message", e.Message)
-		assert.Equal(t, "Normal", e.Type)
-		assert.Equal(t, "WorkflowNodeSucceeded", e.Reason)
-	}
-}
-
-func Test_eventType(t *testing.T) {
-	assert.Equal(t, corev1.EventTypeWarning, eventType(wfv1.NodeError))
-	assert.Equal(t, corev1.EventTypeWarning, eventType(wfv1.NodeFailed))
-	assert.Equal(t, corev1.EventTypeNormal, eventType(wfv1.NodeSucceeded))
-}
-
-func Test_nodePhaseReason(t *testing.T) {
-	assert.Equal(t, EventReasonWorkflowNodeError, nodePhaseReason(wfv1.NodeError))
-	assert.Equal(t, EventReasonWorkflowNodeFailed, nodePhaseReason(wfv1.NodeFailed))
-	assert.Equal(t, EventReasonWorkflowNodeSucceeded, nodePhaseReason(wfv1.NodeSucceeded))
-}
-
-func Test_nodeMessage(t *testing.T) {
-	assert.Equal(t, "Succeeded node my-node", nodeMessage(&wfv1.NodeStatus{Phase: wfv1.NodeSucceeded, Name: "my-node"}))
-	assert.Equal(t, "Succeeded node my-node: my-message", nodeMessage(&wfv1.NodeStatus{Phase: wfv1.NodeSucceeded, Name: "my-node", Message: "my-message"}))
-}
diff --git a/workflow/controller/controller_test.go b/workflow/controller/controller_test.go
index afbace04f..185f7eedc 100644
--- a/workflow/controller/controller_test.go
+++ b/workflow/controller/controller_test.go
@@ -14,6 +14,7 @@ import (
 	"k8s.io/client-go/kubernetes/fake"
 	k8stesting "k8s.io/client-go/testing"
 	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 	"sigs.k8s.io/yaml"
 
@@ -121,7 +122,7 @@ func newController(objects ...runtime.Object) (context.CancelFunc, *WorkflowCont
 		},
 		kubeclientset:   kube,
 		wfclientset:     wfclientset,
-		completedPods:   make(chan string, 512),
+		completedPods:   make(chan string, 16),
 		wfInformer:      wfInformer,
 		wftmplInformer:  wftmplInformer,
 		cwftmplInformer: cwftmplInformer,
@@ -130,6 +131,7 @@ func newController(objects ...runtime.Object) (context.CancelFunc, *WorkflowCont
 		wfArchive:       sqldb.NullWorkflowArchive,
 		hydrator:        hydratorfake.Noop,
 		metrics:         metrics.New(metrics.ServerConfig{}, metrics.ServerConfig{}),
+		eventRecorder:   record.NewFakeRecorder(16),
 	}
 	return cancel, controller
 }
diff --git a/workflow/controller/operator_test.go b/workflow/controller/operator_test.go
index 31cb386e5..f6a617710 100644
--- a/workflow/controller/operator_test.go
+++ b/workflow/controller/operator_test.go
@@ -13,12 +13,12 @@ import (
 	apiv1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/tools/record"
 	"sigs.k8s.io/yaml"
 
 	"github.com/argoproj/argo/config"
 	wfv1 "github.com/argoproj/argo/pkg/apis/workflow/v1alpha1"
 	"github.com/argoproj/argo/test"
-	"github.com/argoproj/argo/util/argo"
 	"github.com/argoproj/argo/workflow/common"
 	hydratorfake "github.com/argoproj/argo/workflow/hydrator/fake"
 	"github.com/argoproj/argo/workflow/util"
@@ -2730,54 +2730,18 @@ func TestEventInvalidSpec(t *testing.T) {
 	assert.NoError(t, err)
 	woc := newWorkflowOperationCtx(wf, controller)
 	woc.operate()
-	events, err := controller.kubeclientset.CoreV1().Events("").List(metav1.ListOptions{})
-	assert.NoError(t, err)
-	assert.Equal(t, 2, len(events.Items))
-	runningEvent := events.Items[0]
-	assert.Equal(t, argo.EventReasonWorkflowRunning, runningEvent.Reason)
-	invalidSpecEvent := events.Items[1]
-	assert.Equal(t, argo.EventReasonWorkflowFailed, invalidSpecEvent.Reason)
-	assert.Equal(t, "invalid spec: template name '123' undefined", invalidSpecEvent.Message)
+	events := getEvents(controller, 2)
+	assert.Equal(t, "Normal WorkflowRunning Workflow Running", events[0])
+	assert.Equal(t, "Warning WorkflowFailed invalid spec: template name '123' undefined", events[1])
 }
 
-var timeout = `
-apiVersion: argoproj.io/v1alpha1
-kind: Workflow
-metadata:
-  name: timeout-template
-spec:
-  entrypoint: sleep
-  activeDeadlineSeconds: 1
-  templates:
-  - name: sleep
-    container:
-      image: alpine:latest
-      command: [sh, -c, sleep 10]
-`
-
-func TestEventTimeout(t *testing.T) {
-	// Test whether a WorkflowTimedOut event is emitted in case of timeout
-	cancel, controller := newController()
-	defer cancel()
-	wfcset := controller.wfclientset.ArgoprojV1alpha1().Workflows("")
-	wf := unmarshalWF(timeout)
-	wf, err := wfcset.Create(wf)
-	assert.NoError(t, err)
-	woc := newWorkflowOperationCtx(wf, controller)
-	woc.operate()
-	makePodsPhase(t, apiv1.PodRunning, controller.kubeclientset, wf.ObjectMeta.Namespace)
-	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
-	assert.NoError(t, err)
-	woc = newWorkflowOperationCtx(wf, controller)
-	time.Sleep(10 * time.Second)
-	woc.operate()
-	events, err := controller.kubeclientset.CoreV1().Events("").List(metav1.ListOptions{})
-	assert.NoError(t, err)
-	assert.Equal(t, 2, len(events.Items))
-	runningEvent := events.Items[0]
-	assert.Equal(t, argo.EventReasonWorkflowRunning, runningEvent.Reason)
-	timeoutEvent := events.Items[1]
-	assert.Equal(t, argo.EventReasonWorkflowFailed, timeoutEvent.Reason)
+func getEvents(controller *WorkflowController, num int) []string {
+	c := controller.eventRecorder.(*record.FakeRecorder).Events
+	events := make([]string, num)
+	for i := 0; i < num; i++ {
+		events[i] = <-c
+	}
+	return events
 }
 
 var failLoadArtifactRepoCm = `
@@ -2812,14 +2776,9 @@ func TestEventFailArtifactRepoCm(t *testing.T) {
 	assert.NoError(t, err)
 	woc := newWorkflowOperationCtx(wf, controller)
 	woc.operate()
-	events, err := controller.kubeclientset.CoreV1().Events("").List(metav1.ListOptions{})
-	assert.NoError(t, err)
-	assert.Equal(t, 2, len(events.Items))
-	runningEvent := events.Items[0]
-	assert.Equal(t, argo.EventReasonWorkflowRunning, runningEvent.Reason)
-	failEvent := events.Items[1]
-	assert.Equal(t, argo.EventReasonWorkflowFailed, failEvent.Reason)
-	assert.Equal(t, "Failed to load artifact repository configMap: failed to find artifactory ref {,}/artifact-repository#config", failEvent.Message)
+	events := getEvents(controller, 2)
+	assert.Equal(t, "Normal WorkflowRunning Workflow Running", events[0])
+	assert.Equal(t, "Warning WorkflowFailed Failed to load artifact repository configMap: failed to find artifactory ref {,}/artifact-repository#config", events[1])
 }
 
 var pdbwf = `
diff --git a/workflow/cron/operator_test.go b/workflow/cron/operator_test.go
index 925a7051a..7acea475c 100644
--- a/workflow/cron/operator_test.go
+++ b/workflow/cron/operator_test.go
@@ -183,6 +183,63 @@ func TestCronWorkflowConditionSubmissionError(t *testing.T) {
 	assert.Len(t, woc.cronWf.Status.Conditions, 1)
 	submissionErrorCond := woc.cronWf.Status.Conditions[0]
 	assert.Equal(t, v1.ConditionTrue, submissionErrorCond.Status)
-	assert.Equal(t, v1alpha1.ConditionTypeSubmissionError, submissionErrorCond.Type)
+	assert.Equal(t, v1alpha1.ConditionTypeSpecError, submissionErrorCond.Type)
 	assert.Contains(t, submissionErrorCond.Message, "'bad template name' is invalid")
 }
+
+var specError = `
+apiVersion: argoproj.io/v1alpha1
+kind: CronWorkflow
+metadata:
+  name: hello-world
+spec:
+  concurrencyPolicy: Replace
+  failedJobsHistoryLimit: 4
+  schedule: 10 * * 12737123 *
+  startingDeadlineSeconds: 0
+  successfulJobsHistoryLimit: 4
+  timezone: America/Los_Angeles
+  workflowSpec:
+    arguments: {}
+    entrypoint: whalesay
+    templates:
+    - arguments: {}
+      container:
+        args:
+        - "\U0001F553 hello world"
+        command:
+        - cowsay
+        image: docker/whalesay:latest
+        name: ""
+        resources: {}
+      inputs: {}
+      metadata: {}
+      name: whalesay
+      outputs: {}
+`
+
+func TestSpecError(t *testing.T) {
+	var cronWf v1alpha1.CronWorkflow
+	err := yaml.Unmarshal([]byte(specError), &cronWf)
+	assert.NoError(t, err)
+
+	cs := fake.NewSimpleClientset()
+	testMetrics := metrics.New(metrics.ServerConfig{}, metrics.ServerConfig{})
+	woc := &cronWfOperationCtx{
+		wfClientset: cs,
+		wfClient:    cs.ArgoprojV1alpha1().Workflows(""),
+		cronWfIf:    cs.ArgoprojV1alpha1().CronWorkflows(""),
+		wfLister:    &fakeLister{},
+		cronWf:      &cronWf,
+		log:         logrus.WithFields(logrus.Fields{}),
+		metrics:     &testMetrics,
+	}
+
+	err = woc.validateCronWorkflow()
+	assert.Error(t, err)
+	assert.Len(t, woc.cronWf.Status.Conditions, 1)
+	submissionErrorCond := woc.cronWf.Status.Conditions[0]
+	assert.Equal(t, v1.ConditionTrue, submissionErrorCond.Status)
+	assert.Equal(t, v1alpha1.ConditionTypeSpecError, submissionErrorCond.Type)
+	assert.Contains(t, submissionErrorCond.Message, "cron schedule is malformed: End of range (12737123) above maximum (12): 12737123")
+}