Repository: diggerhq/digger
Pull request #387 (2023-06-19 10:50:24)

merge develop into feat/support-org-level-rbac


Patch:
diff --git a/README.md b/README.md
index 335ed9e0..c87e3cca 100644
--- a/README.md
+++ b/README.md
@@ -2,87 +2,63 @@
 
 <h2 align="center">
   <a href="https://join.slack.com/t/diggertalk/shared_invite/zt-1tocl4w0x-E3RkpPiK7zQkehl8O78g8Q">Slack</a> |
-  <a href="https://docs.digger.dev/">Docs</a>
+  <a href="https://docs.digger.dev/">Docs</a> | 
+  <a href="https://www.loom.com/share/51f27994d95f4dc5bb6eea579e1fa8dc?sid=403f161a-6c0b-44ac-af57-cc9b56190f64">Demo Video</a>
 </h2>
 
 CI/CD for Terraform is [tricky](https://itnext.io/pains-in-terraform-collaboration-249a56b4534e). To make life easier, specialised CI systems aka [TACOS](https://itnext.io/spice-up-your-infrastructure-as-code-with-tacos-1a9c179e0783) exist - Terraform Cloud, Spacelift, Atlantis, etc.
 
 But why have 2 CI systems? Why not reuse the async jobs infrastructure with compute, orchestration, logs, etc of your existing CI?
 
-Digger runs terraform natively in your CI and takes care of the other bits - locks, plan artifacts and so on. [Demo video](https://www.loom.com/share/e201e639a73941e0b5508710377a6106)
+Digger runs terraform natively in your CI. This is:
 
+- Secure, because cloud access secrets aren't shared with a third-party
+- Cost-effective, because you are not paying for additional compute just to run your terraform
 
 ## Features
-- 👟 Runner-less. Terraform runs in the compute environment of your existing CI such as Github Actions, Gitlab, Argo etc.
-- 🪶 Minimal / no backend. Digger's own backend is a serverless function; it is only needed for certain CI environments (eg Gitlab)
-- 🔒 Code-level locks. Avoid race conditions across multiple PRs. Similar to Atlantis workflow.
-- ☁️ Multi-cloud. At the moment Digger supports AWS and GCP; Azure support coming in April 2023 (yes, in a few weeks).
-- 💥 Projects. Allow to isolate terraform runs and locks to a specific directory
-- 💥 Terragrunt support
-- 💥 Workspaces support
+- Terraform plan and apply in pull request comments
+- Any VCS - Github, Gitlab, Azure Repos, etc
+- Any CI - Github Actions, Gitlab, Azure DevOps, etc
+- Any cloud provider - AWS, GCP, Azure
+- Private runners - thanks to the fact that there are no separate runners! Your existing CI's compute environment is used
+- Open Policy Agent (OPA) support for RBAC
+- PR-level locks (on top of Terraform native state locks, similar to Atlantis) to avoid race conditions across multiple PRs
+- Terragrunt, Workspaces, multiple Terraform versions, static analysis via Checkov, plan persistence, ...
+- Drift detection - coming soon
+- Cost estimation - coming soon
 
-## Roadmap
+## Getting Started
 
-Need a feature that's not listed? Book a [community feedback call](https://calendly.com/diggerdev/community-feedback) - we ship fast ✅
+- [Github Actions + AWS](https://docs.digger.dev/getting-started/github-actions-+-aws)
+- [Github Actions + GCP](https://docs.digger.dev/getting-started/github-actions-and-gcp)
+- [Gitlab Pipelines + GCP](https://docs.digger.dev/getting-started/gitlab-pipelines-+-aws)
+- [Azure DevOps](https://docs.digger.dev/getting-started/azure-devops)
 
-- ✅ GCP support. Store PR locks in GCP storage buckets. Shipped in [#50](https://github.com/diggerhq/digger/pull/50)
-- ✅ Workspaces support. Allow usage of Terraform CLI Workspaces. Shipped in [#72](https://github.com/diggerhq/digger/pull/72)
-- ✅ Terragrunt support. Config option to run terragrunt wrapper. Shipped in [#76](https://github.com/diggerhq/digger/pull/76)
-- ✅ Azure support using Storage Account Tables WIP: [#122](https://github.com/diggerhq/digger/pull/122)
-- ⌚ AWS CodeBuild support
-- ⌛ Gitlab Support
-- ⌛ Configurable workflows. In addition to Atlantis-style (apply, then merge) also support "apply-only" and "no-lock"
-- ⌛ Bitbucket Support
-- ⌛ Jenkins Support
+## How it works
 
-## How to use
+Digger has 2 main components:
+- CLI that runs inside your CI and calls terraform with the right arguments
+- Orchestrator - a minimal backend (that can also be self-hosted) that triggers CI jobs in response to events such as PR comments
 
-This is demo flow with a sample repo using local state - for real world scenario you'll need to configure remote backend (S3 + DynamoDB) and add a [workflow file](https://github.com/diggerhq/digger_demo/blob/main/.github/workflows/plan.yml) to the root of the repo.
+Digger also stores PR-level locks and plan cache in your cloud account (DynamoDB + S3 on AWS, equivalents in other cloud providers)
 
-1. Fork the [demo repository](https://github.com/diggerhq/digger_demo_multienv)
-2. Enable Actions (by default workflows won't trigger in a fork)
-
-<img width="1441" alt="Screenshot 2023-02-24 at 20 24 08" src="https://user-images.githubusercontent.com/1280498/221291130-6831d45a-008f-452f-91d3-37ba133d7cbb.png">
-
-
-2. In your repository settings > Actions ensure that the Workflow Read and Write permissions are assigned - This will allow the workflow  to post comments on your PRs
-<img width="1017" alt="Screen Shot 2023-03-01 at 12 02 59 PM" src="https://user-images.githubusercontent.com/1627972/222136385-c7cb8f2c-1731-475d-b3a4-78b0d79a3874.png">
-
-3. Add environment variables into your Github Action Secrets (NOTE: This step is optional if you just want to test out the Action with `null_resource`)
-- AWS_ACCESS_KEY_ID
-- AWS_SECRET_ACCESS_KEY 
-4. make a change and create a PR - this will create a lock
-5. comment `digger plan` - terraform plan output will be added as comment. If you don't see a comment (bug) - check out job output
-6. create another PR - plan or apply won’t work in this PR until the first lock is released
-7. you should see `Locked by PR #1` comment. The action logs will display "Project locked" error message.
-
-## Remote backend and state-level locks
-
-Digger does not interfere with your remote backend setup. You could be using [S3 backend](https://developer.hashicorp.com/terraform/language/settings/backends/s3) or TF cloud's [remote backend](https://developer.hashicorp.com/terraform/language/settings/backends/remote) or [some other way](https://developer.hashicorp.com/terraform/language/settings/backends/configuration)
-
-Digger also doesn't differentiate locks based on statefiles - if a PR is locked, it's locked for all "instances" of state (aka [Terraform CLI Workspaces](https://developer.hashicorp.com/terraform/cloud-docs/workspaces#terraform-cloud-vs-terraform-cli-workspaces))
-
-state-level locks will keep working normally because are handled by terraform itself ([same as in Atlantis](https://www.runatlantis.io/docs/locking.html#relationship-to-terraform-state-locking))
-
-
-## Notes
-- We perform anonymous usage tracking. No sensitive or personal / identifyable data is logged. You can see what is tracked in [`pkg/utils/usage.go`](https://github.com/diggerhq/digger/blob/main/pkg/utils/usage.go)
+## Telementry
+No sensitive or personal / identifyable data is logged. You can see what is tracked in [`pkg/utils/usage.go`](https://github.com/diggerhq/digger/blob/main/pkg/utils/usage.go)
 
 ## Contributing
-**If you are considering using digger within your organisation
-please [reach out to us](https://join.slack.com/t/diggertalk/shared_invite/zt-1q6npg7ib-9dwRbJp8sQpSr2fvWzt9aA).**
 
-To contribute to Digger please follow our [Contributing guide](CONTRIBUTING.md)
+We love contributions. Check out our [contribiting guide](CONTRIBUTING.md) to get started. 
+
+Not sure where to get started? You can:
 
-## FAQ
+-   [Book a free, non-pressure pairing session / code walkthrough with one of our teammates](https://calendly.com/diggerdev/digger-pro-demo-clone)!
+-   Join our <a href="https://join.slack.com/t/diggertalk/shared_invite/zt-1tocl4w0x-E3RkpPiK7zQkehl8O78g8Q">Slack</a>, and ask us any questions there.
 
-Q) **Since you're FOSS I assume you plan to monetize by selling support? Or...?**
 
-A) We are a vc-backed startup fully focused on this tool; in terms of monetization - we are currently in the process of launching Digger Pro. Check out the features [here](https://digger.dev/#plans) and feel free to book a [demo](https://bit.ly/diggerpro) if interested.
+## Resources
 
-## Links
-- [The case for a 'Headless Terraform IDP' for terraform self service](https://medium.com/@DiggerHQ/the-case-for-headless-terraform-idp-5bc5a873805f)
-- [Can GitHub actions be used as a CI/CD for Terraform?](https://medium.com/@DiggerHQ/can-github-actions-be-used-as-a-ci-cd-for-terraform-e4ac59a38b0)
-- [Why are people using Terraform Cloud?](https://www.reddit.com/r/Terraform/comments/1132qf3/why_are_people_using_terraform_cloud_i_may_be/)
-- [The Pains of Terraform Collaboration](https://itnext.io/pains-in-terraform-collaboration-249a56b4534e)
-- [Four Great Alternatives to HashiCorp’s Terraform Cloud](https://medium.com/@elliotgraebert/four-great-alternatives-to-hashicorps-terraform-cloud-6e0a3a0a5482)
+- [Docs](https://docs.digger.dev/) for comprehensive documentation and guides
+- [Slack](https://join.slack.com/t/diggertalk/shared_invite/zt-1tocl4w0x-E3RkpPiK7zQkehl8O78g8Q) for discussion with the community and Infisical team.
+- [GitHub](https://github.com/diggerhq/digger) for code, issues, and pull request
+- [Medium](https://medium.com/@DiggerHQ) for terraform automation and collaboration insights, articles, tutorials, and updates.
+- [Roadmap](https://diggerdev.notion.site/Digger-Roadmap-845a90fb17954afca80431580e1b3958?pvs=4) for planned features
diff --git a/action.yml b/action.yml
index bcc0677d..df46e022 100644
--- a/action.yml
+++ b/action.yml
@@ -86,6 +86,10 @@ inputs:
     description: Setup tfenv
     required: false
     default: 'false'
+  post-plans-as-one-comment:
+    description: Post plans as one comment
+    required: false
+    default: 'false'
 outputs:
   output:
     value: ${{ steps.digger.outputs.output }}
@@ -186,6 +190,7 @@ runs:
         POLICY_CHECK_ENABLED: ${{ inputs.policy-check-enabled == 'true' }}
         DIGGER_CLOUD_TOKEN: ${{ inputs.digger-cloud-token }}
         DIGGER_CLOUD_HOSTNAME: ${{ inputs.digger-cloud-hostname }}
+        ACCUMULATE_PLANS: ${{ inputs.post-plans-as-one-comment == 'true' }}
       run: |
           cd ${{ github.action_path }}
           go build -o digger ./cmd/digger
@@ -203,6 +208,7 @@ runs:
         POLICY_CHECK_ENABLED: ${{ inputs.policy-check-enabled == 'true' }}
         DIGGER_CLOUD_TOKEN: ${{ inputs.digger-cloud-token }}
         DIGGER_CLOUD_HOSTNAME: ${{ inputs.digger-cloud-hostname }}
+        ACCUMULATE_PLANS: ${{ inputs.post-plans-as-one-comment == 'true' }}
       id: digger
       shell: bash
       run: |
diff --git a/cmd/digger/main.go b/cmd/digger/main.go
index 135a15f5..85918c20 100644
--- a/cmd/digger/main.go
+++ b/cmd/digger/main.go
@@ -20,11 +20,12 @@ import (
 	"digger/pkg/usage"
 	"digger/pkg/utils"
 	"fmt"
-	"github.com/google/go-github/v51/github"
 	"log"
 	"net/http"
 	"os"
 	"strings"
+
+	"github.com/google/go-github/v53/github"
 )
 
 func gitHubCI(lock core_locking.Lock, policyChecker core_policy.Checker) {
diff --git a/go.mod b/go.mod
index ac0860a3..03ae53dd 100644
--- a/go.mod
+++ b/go.mod
@@ -8,8 +8,8 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1
 	github.com/aws/aws-sdk-go v1.44.282
 	github.com/bmatcuk/doublestar/v4 v4.6.0
-	github.com/caarlos0/env/v7 v7.1.0
-	github.com/google/go-github/v51 v51.0.0
+	github.com/caarlos0/env/v8 v8.0.0
+	github.com/google/go-github/v53 v53.1.0
 	github.com/google/uuid v1.3.0
 	github.com/jinzhu/copier v0.3.5
 	github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5
@@ -33,6 +33,7 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/cloudflare/circl v1.3.3 // indirect
+	github.com/davecgh/go-spew v1.1.1
 	github.com/ghodss/yaml v1.0.0 // indirect
 	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-logr/logr v1.2.4 // indirect
@@ -48,9 +49,11 @@ require (
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
+	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
 	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_golang v1.15.1 // indirect
 	github.com/prometheus/client_model v0.3.0 // indirect
 	github.com/prometheus/common v0.42.0 // indirect
@@ -79,9 +82,3 @@ require (
 	google.golang.org/protobuf v1.30.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 )
-
-require (
-	github.com/davecgh/go-spew v1.1.1
-	github.com/jmespath/go-jmespath v0.4.0 // indirect
-	github.com/pmezard/go-difflib v1.0.0 // indirect
-)
diff --git a/go.sum b/go.sum
index ce6aee27..8743a4c7 100644
--- a/go.sum
+++ b/go.sum
@@ -37,8 +37,8 @@ github.com/bmatcuk/doublestar/v4 v4.6.0 h1:HTuxyug8GyFbRkrffIpzNCSK4luc0TY3wzXvz
 github.com/bmatcuk/doublestar/v4 v4.6.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
-github.com/caarlos0/env/v7 v7.1.0 h1:9lzTF5amyQeWHZzuZeKlCb5FWSUxpG1js43mhbY8ozg=
-github.com/caarlos0/env/v7 v7.1.0/go.mod h1:LPPWniDUq4JaO6Q41vtlyikhMknqymCLBw0eX4dcH1E=
+github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
+github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
 github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
@@ -111,8 +111,8 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-github/v51 v51.0.0 h1:KCjsbgPV28VoRftdP+K2mQL16jniUsLAJknsOVKwHyU=
-github.com/google/go-github/v51 v51.0.0/go.mod h1:kZj/rn/c1lSUbr/PFWl2hhusPV7a5XNYKcwPrd5L3Us=
+github.com/google/go-github/v53 v53.1.0 h1:mKJnR9lzZwD1fvbp27aK1i6rxyAbycWsXlN+r9JKPqM=
+github.com/google/go-github/v53 v53.1.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
diff --git a/pkg/azure/azure.go b/pkg/azure/azure.go
index 4f0d4893..d5911390 100644
--- a/pkg/azure/azure.go
+++ b/pkg/azure/azure.go
@@ -413,11 +413,8 @@ func ConvertAzureEventToCommands(parseAzureContext Azure, impactedProjects []con
 		}
 		return commandsPerProject, true, nil
 	case AzurePrCommented:
-
-		diggerCommand := parseAzureContext.Event.(AzureCommentEvent).Resource.Comment.Content
-
+		diggerCommand := strings.ToLower(parseAzureContext.Event.(AzureCommentEvent).Resource.Comment.Content)
 		coversAllImpactedProjects := true
-
 		runForProjects := impactedProjects
 
 		if requestedProject != nil {
diff --git a/pkg/configuration/converters.go b/pkg/configuration/converters.go
index e0828b7f..98d2a8bc 100644
--- a/pkg/configuration/converters.go
+++ b/pkg/configuration/converters.go
@@ -75,20 +75,26 @@ func copyWorkflowConfiguration(config *WorkflowConfigurationYaml) *WorkflowConfi
 	return &result
 }
 
+// converts dict of WorkflowYaml's to dict of Workflow's
 func copyWorkflows(workflows map[string]*WorkflowYaml) map[string]Workflow {
 	result := make(map[string]Workflow, len(workflows))
 	for i, w := range workflows {
-		envVars := copyTerraformEnvConfig(w.EnvVars)
-		plan := copyStage(w.Plan)
-		apply := copyStage(w.Apply)
-		configuration := copyWorkflowConfiguration(w.Configuration)
-		item := Workflow{
-			envVars,
-			plan,
-			apply,
-			configuration,
+		if w == nil {
+			item := *defaultWorkflow()
+			result[i] = item
+		} else {
+			envVars := copyTerraformEnvConfig(w.EnvVars)
+			plan := copyStage(w.Plan)
+			apply := copyStage(w.Apply)
+			configuration := copyWorkflowConfiguration(w.Configuration)
+			item := Workflow{
+				envVars,
+				plan,
+				apply,
+				configuration,
+			}
+			result[i] = item
 		}
-		result[i] = item
 	}
 	return result
 }
@@ -159,5 +165,17 @@ func ConvertDiggerYamlToConfig(diggerYaml *DiggerConfigYaml, workingDir string,
 			}
 		}
 	}
+
+	// if one of the workflows is missing Plan or Apply we copy default values
+	for _, w := range diggerConfig.Workflows {
+		defaultWorkflow := *defaultWorkflow()
+		if w.Plan == nil {
+			w.Plan = defaultWorkflow.Plan
+		}
+		if w.Apply == nil {
+			w.Apply = defaultWorkflow.Apply
+		}
+	}
+
 	return &diggerConfig, nil
 }
diff --git a/pkg/core/execution/execution.go b/pkg/core/execution/execution.go
index d32087a6..6cdc4cb6 100644
--- a/pkg/core/execution/execution.go
+++ b/pkg/core/execution/execution.go
@@ -43,10 +43,11 @@ func (d DiggerExecutor) storedPlanFilePath() string {
 	return path.Join(d.ProjectNamespace, d.planFileName())
 }
 
-func (d DiggerExecutor) Plan() (bool, error) {
+func (d DiggerExecutor) Plan() (bool, string, error) {
+	plan := ""
 	locked, err := d.ProjectLock.Lock()
 	if err != nil {
-		return false, fmt.Errorf("error locking project: %v", err)
+		return false, "", fmt.Errorf("error locking project: %v", err)
 	}
 	log.Printf("Lock result: %t\n", locked)
 	if locked {
@@ -68,7 +69,7 @@ func (d DiggerExecutor) Plan() (bool, error) {
 			if step.Action == "init" {
 				_, _, err := d.TerraformExecutor.Init(step.ExtraArgs, d.StateEnvVars)
 				if err != nil {
-					return false, fmt.Errorf("error running init: %v", err)
+					return false, "", fmt.Errorf("error running init: %v", err)
 				}
 			}
 			if step.Action == "plan" {
@@ -76,29 +77,27 @@ func (d DiggerExecutor) Plan() (bool, error) {
 				planArgs = append(planArgs, step.ExtraArgs...)
 				isNonEmptyPlan, stdout, stderr, err := d.TerraformExecutor.Plan(planArgs, d.CommandEnvVars)
 				if err != nil {
-					return false, fmt.Errorf("error executing plan: %v", err)
+					return false, "", fmt.Errorf("error executing plan: %v", err)
 				}
 				if d.PlanStorage != nil {
 					planExists, err := d.PlanStorage.PlanExists(d.storedPlanFilePath())
 					if err != nil {
-						return false, fmt.Errorf("error checking if plan exists: %v", err)
+						return false, "", fmt.Errorf("error checking if plan exists: %v", err)
 					}
 
 					if planExists {
 						err = d.PlanStorage.DeleteStoredPlan(d.storedPlanFilePath())
 						if err != nil {
-							return false, fmt.Errorf("error deleting plan: %v", err)
+							return false, "", fmt.Errorf("error deleting plan: %v", err)
 						}
 					}
 
 					err = d.PlanStorage.StorePlan(d.localPlanFilePath(), d.storedPlanFilePath())
 					if err != nil {
-						return false, fmt.Errorf("error storing plan: %v", err)
+						return false, "", fmt.Errorf("error storing plan: %v", err)
 					}
 				}
-				plan := cleanupTerraformPlan(isNonEmptyPlan, err, stdout, stderr)
-				comment := utils.GetTerraformOutputAsCollapsibleComment("Plan for **"+d.ProjectLock.LockId()+"**", plan)
-				err = d.Reporter.Report(comment)
+				plan = cleanupTerraformPlan(isNonEmptyPlan, err, stdout, stderr)
 				if err != nil {
 					fmt.Printf("error publishing comment: %v", err)
 				}
@@ -112,13 +111,13 @@ func (d DiggerExecutor) Plan() (bool, error) {
 				log.Printf("Running %v for **%v**\n", step.Value, d.ProjectLock.LockId())
 				_, _, err := d.CommandRunner.Run(d.ProjectPath, step.Shell, commands)
 				if err != nil {
-					return false, fmt.Errorf("error running command: %v", err)
+					return false, "", fmt.Errorf("error running command: %v", err)
 				}
 			}
 		}
-		return true, nil
+		return true, plan, nil
 	}
-	return false, nil
+	return false, plan, nil
 }
 
 func (d DiggerExecutor) Apply() (bool, error) {
diff --git a/pkg/digger/digger.go b/pkg/digger/digger.go
index 2be93d00..3e04dde3 100644
--- a/pkg/digger/digger.go
+++ b/pkg/digger/digger.go
@@ -10,6 +10,7 @@ import (
 	"digger/pkg/core/runners"
 	"digger/pkg/core/storage"
 	"digger/pkg/core/terraform"
+	"digger/pkg/core/utils"
 	"digger/pkg/locking"
 	"digger/pkg/usage"
 	"errors"
@@ -57,13 +58,29 @@ func DetectCI() CIName {
 
 }
 
-func RunCommandsPerProject(commandsPerProject []models.ProjectCommand, projectNamespace string, requestedBy string, eventName string, prNumber int, ciService ci.CIService, lock core_locking.Lock, reporter reporting.Reporter, planStorage storage.PlanStorage, policyChecker policy.Checker, workingDir string) (bool, bool, error) {
+func RunCommandsPerProject(
+	commandsPerProject []models.ProjectCommand,
+	projectNamespace string,
+	requestedBy string,
+	eventName string,
+	prNumber int,
+	ciService ci.CIService,
+	lock core_locking.Lock,
+	reporter reporting.Reporter,
+	planStorage storage.PlanStorage,
+	policyChecker policy.Checker,
+	workingDir string,
+) (bool, bool, error) {
+	accumulatePlans := os.Getenv("ACCUMULATE_PLANS") == "true"
 	appliesPerProject := make(map[string]bool)
+	plansToPublish := make([]string, 0)
+
 	organisation := strings.Split(projectNamespace, "/")[0]
 	teams, err := ciService.GetUserTeams(organisation, requestedBy)
 	if err != nil {
 		fmt.Printf("Error while fetching user teams for CI service: %v", err)
 	}
+  
 	for _, projectCommands := range commandsPerProject {
 		for _, command := range projectCommands.Commands {
 
@@ -125,12 +142,21 @@ func RunCommandsPerProject(commandsPerProject []models.ProjectCommand, projectNa
 			case "digger plan":
 				usage.SendUsageRecord(requestedBy, eventName, "plan")
 				ciService.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/plan")
-				planPerformed, err := diggerExecutor.Plan()
+				planPerformed, plan, err := diggerExecutor.Plan()
 				if err != nil {
 					log.Printf("Failed to run digger plan command. %v", err)
 					ciService.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/plan")
 					return false, false, fmt.Errorf("failed to run digger plan command. %v", err)
 				} else if planPerformed {
+					comment := utils.GetTerraformOutputAsCollapsibleComment("Plan for **"+projectLock.LockId()+"**", plan)
+					if accumulatePlans {
+						plansToPublish = append(plansToPublish, comment)
+					} else {
+						err = reporter.Report(comment)
+						if err != nil {
+							log.Printf("Failed to report plan. %v", err)
+						}
+					}
 					ciService.SetStatus(prNumber, "success", projectCommands.ProjectName+"/plan")
 				}
 			case "digger apply":
@@ -177,6 +203,13 @@ func RunCommandsPerProject(commandsPerProject []models.ProjectCommand, projectNa
 		}
 	}
 
+	if len(plansToPublish) > 0 {
+		err := reporter.Report(strings.Join(plansToPublish, "\n"))
+		if err != nil {
+			log.Printf("Failed to report plans. %v", err)
+		}
+	}
+
 	allAppliesSuccess := true
 	for _, success := range appliesPerProject {
 		if !success {
diff --git a/pkg/github/github.go b/pkg/github/github.go
index 009e8bf3..059e7037 100644
--- a/pkg/github/github.go
+++ b/pkg/github/github.go
@@ -12,7 +12,7 @@ import (
 	"log"
 	"strings"
 
-	"github.com/google/go-github/v51/github"
+	"github.com/google/go-github/v53/github"
 )
 
 func NewGitHubService(ghToken string, repoName string, owner string) ci.CIService {
@@ -225,8 +225,10 @@ func ConvertGithubEventToCommands(event models.Event, impactedProjects []configu
 			}
 		}
 
+		diggerCommand := strings.ToLower(event.Comment.Body)
+
 		for _, command := range supportedCommands {
-			if strings.Contains(event.Comment.Body, command) {
+			if strings.Contains(diggerCommand, command) {
 				for _, project := range runForProjects {
 					workflow, ok := workflows[project.Workflow]
 					if !ok {
diff --git a/pkg/gitlab/gitlab.go b/pkg/gitlab/gitlab.go
index b1a8895e..fa8a2fb6 100644
--- a/pkg/gitlab/gitlab.go
+++ b/pkg/gitlab/gitlab.go
@@ -5,10 +5,11 @@ import (
 	"digger/pkg/core/models"
 	"digger/pkg/utils"
 	"fmt"
-	"github.com/caarlos0/env/v7"
-	go_gitlab "github.com/xanzy/go-gitlab"
 	"log"
 	"strings"
+
+	"github.com/caarlos0/env/v8"
+	go_gitlab "github.com/xanzy/go-gitlab"
 )
 
 // based on https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
@@ -101,7 +102,8 @@ func ProcessGitLabEvent(gitlabContext *GitLabContext, diggerConfig *configuratio
 
 	switch gitlabContext.EventType {
 	case MergeRequestComment:
-		requestedProject := utils.ParseProjectName(gitlabContext.DiggerCommand)
+		diggerCommand := strings.ToLower(gitlabContext.DiggerCommand)
+		requestedProject := utils.ParseProjectName(diggerCommand)
 
 		if requestedProject == "" {
 			return impactedProjects, nil, nil
@@ -315,15 +317,16 @@ func ConvertGitLabEventToCommands(event GitLabEvent, gitLabContext *GitLabContex
 			}
 		}
 
+		diggerCommand := strings.ToLower(gitLabContext.DiggerCommand)
 		for _, command := range supportedCommands {
-			if strings.Contains(gitLabContext.DiggerCommand, command) {
+			if strings.Contains(diggerCommand, command) {
 				for _, project := range runForProjects {
 					workflow, ok := workflows[project.Workflow]
 					if !ok {
 						workflow = workflows["default"]
 					}
 					workspace := project.Workspace
-					workspaceOverride, err := utils.ParseWorkspace(gitLabContext.DiggerCommand)
+					workspaceOverride, err := utils.ParseWorkspace(diggerCommand)
 					if err != nil {
 						return []models.ProjectCommand{}, false, err
 					}
diff --git a/pkg/storage/plan_storage.go b/pkg/storage/plan_storage.go
index 987611d5..0f5451fb 100644
--- a/pkg/storage/plan_storage.go
+++ b/pkg/storage/plan_storage.go
@@ -12,7 +12,7 @@ import (
 	"strconv"
 
 	"cloud.google.com/go/storage"
-	"github.com/google/go-github/v51/github"
+	"github.com/google/go-github/v53/github"
 )
 
 type PlanStorageGcp struct {
diff --git a/pkg/utils/mocks.go b/pkg/utils/mocks.go
index 4f5e0821..077e4f79 100644
--- a/pkg/utils/mocks.go
+++ b/pkg/utils/mocks.go
@@ -42,7 +42,7 @@ func (lock *MockLock) GetLock(resource string) (*int, error) {
 type MockPolicyChecker struct {
 }
 
-func (t MockPolicyChecker) Check(_ string, _ string, _ interface{}) (bool, error) {
+func (t MockPolicyChecker) Check(organisation string, namespace string, projectname string, input interface{}) (bool, error) {
 	return false, nil
 }
 


Test patch:
diff --git a/cmd/digger/main_test.go b/cmd/digger/main_test.go
index 9deb7ad9..f663f61c 100644
--- a/cmd/digger/main_test.go
+++ b/cmd/digger/main_test.go
@@ -987,3 +987,22 @@ func TestGitHubNewPullRequestInMultiEnvProjectContext(t *testing.T) {
 	assert.Equal(t, 1, len(commandsToRunPerProject))
 	assert.NoError(t, err)
 }
+
+func TestGitHubTestPRCommandCaseInsensitivity(t *testing.T) {
+	ghEvent := gh_models.IssueCommentEvent{}
+	ghEvent.Comment.Body = "DiGGeR PlAn"
+
+	project := configuration.Project{Name: "test project", Workflow: "default"}
+	var impactedProjects []configuration.Project
+	impactedProjects = make([]configuration.Project, 1)
+	impactedProjects[0] = project
+	var requestedProject = project
+	workflows := make(map[string]configuration.Workflow, 1)
+	workflows["default"] = configuration.Workflow{}
+
+	commandsToRunPerProject, _, err := github.ConvertGithubEventToCommands(ghEvent, impactedProjects, &requestedProject, workflows)
+
+	assert.Equal(t, 1, len(commandsToRunPerProject))
+	assert.Equal(t, "digger plan", commandsToRunPerProject[0].Commands[0])
+	assert.NoError(t, err)
+}
diff --git a/pkg/configuration/digger_config_test.go b/pkg/configuration/digger_config_test.go
index 1a0b6610..8193b108 100644
--- a/pkg/configuration/digger_config_test.go
+++ b/pkg/configuration/digger_config_test.go
@@ -364,6 +364,96 @@ func TestMissingProjectsReturnsError(t *testing.T) {
 	assert.ErrorContains(t, err, "no projects configuration found")
 }
 
+func TestDiggerConfigCustomWorkflow(t *testing.T) {
+	tempDir, teardown := setUp()
+	defer teardown()
+
+	diggerCfg := `
+projects:
+- name: my-first-app
+  dir: app-one
+  workflow: my_custom_workflow
+workflows:
+  my_custom_workflow:
+    steps:
+      - run: echo "run"
+      - init: terraform init
+      - plan: terraform plan
+`
+	deleteFile := createFile(path.Join(tempDir, "digger.yaml"), diggerCfg)
+	defer deleteFile()
+
+	dg, err := LoadDiggerConfig(tempDir, &FileSystemDirWalker{})
+	assert.NoError(t, err, "expected error to be nil")
+	assert.NotNil(t, dg, "expected digger config to be not nil")
+	assert.Equal(t, "my_custom_workflow", dg.Projects[0].Workflow)
+	_, ok := dg.Workflows["my_custom_workflow"]
+	assert.True(t, ok)
+}
+
+func TestDiggerConfigCustomWorkflowMissingParams(t *testing.T) {
+	tempDir, teardown := setUp()
+	defer teardown()
+
+	// missing workflow config
+	diggerCfg := `
+projects:
+- name: my-first-app
+  dir: app-one
+  workflow: my_custom_workflow
+`
+	deleteFile := createFile(path.Join(tempDir, "digger.yaml"), diggerCfg)
+	defer deleteFile()
+
+	_, err := LoadDiggerConfig(tempDir, &FileSystemDirWalker{})
+	assert.Error(t, err, "failed to find workflow config 'my_custom_workflow' for project 'my-first-app'")
+
+	// steps block is missing for workflows
+	diggerCfg = `
+projects:
+- name: my-first-app
+  dir: app-one
+  workflow: my_custom_workflow
+workflows:
+  my_custom_workflow:
+`
+	deleteFile = createFile(path.Join(tempDir, "digger.yaml"), diggerCfg)
+	defer deleteFile()
+
+	diggerConfig, err := LoadDiggerConfig(tempDir, &FileSystemDirWalker{})
+	assert.Equal(t, "my_custom_workflow", diggerConfig.Projects[0].Workflow)
+	workflow, ok := diggerConfig.Workflows["my_custom_workflow"]
+	assert.True(t, ok)
+	assert.NotNil(t, workflow)
+	assert.NotNil(t, workflow.Plan)
+	assert.NotNil(t, workflow.Apply)
+
+}
+
+func TestDiggerConfigMissingProjectsWorkflow(t *testing.T) {
+	tempDir, teardown := setUp()
+	defer teardown()
+
+	diggerCfg := `
+projects:
+- name: my-first-app
+  dir: app-one
+  workflow: my_custom_workflow
+workflows:
+  my_custom_workflow_no_one_use:
+    steps:
+      - run: echo "run"
+      - init: terraform init
+      - plan: terraform plan
+`
+	deleteFile := createFile(path.Join(tempDir, "digger.yaml"), diggerCfg)
+	defer deleteFile()
+
+	_, err := LoadDiggerConfig(tempDir, &FileSystemDirWalker{})
+	assert.Equal(t, "failed to find workflow config 'my_custom_workflow' for project 'my-first-app'", err.Error())
+
+}
+
 func createTempDir() string {
 	dir, err := os.MkdirTemp("", "tmp")
 	if err != nil {
diff --git a/pkg/digger/digger_test.go b/pkg/digger/digger_test.go
index 8ef3760f..397a921f 100644
--- a/pkg/digger/digger_test.go
+++ b/pkg/digger/digger_test.go
@@ -245,7 +245,7 @@ func TestCorrectCommandExecutionWhenPlanning(t *testing.T) {
 
 	commandStrings := allCommandsInOrderWithParams(terraformExecutor, commandRunner, prManager, lock, planStorage)
 
-	assert.Equal(t, []string{"Lock ", "Init ", "Plan -out #.tfplan", "PlanExists #.tfplan", "StorePlan #.tfplan", "LockId ", "PublishComment 1 <details>\n  <summary>Plan for ****</summary>\n\n  ```terraform\n\n  ```\n</details>", "LockId ", "Run   echo"}, commandStrings)
+	assert.Equal(t, []string{"Lock ", "Init ", "Plan -out #.tfplan", "PlanExists #.tfplan", "StorePlan #.tfplan", "LockId ", "Run   echo"}, commandStrings)
 }
 
 func allCommandsInOrderWithParams(terraformExecutor *MockTerraformExecutor, commandRunner *MockCommandRunner, prManager *MockPRManager, lock *MockProjectLock, planStorage *MockPlanStorage) []string {
diff --git a/pkg/integration/integration_test.go b/pkg/integration/integration_test.go
index d1589990..89b2d379 100644
--- a/pkg/integration/integration_test.go
+++ b/pkg/integration/integration_test.go
@@ -20,7 +20,7 @@ import (
 	awssdk "github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/dynamodb"
-	"github.com/google/go-github/v51/github"
+	"github.com/google/go-github/v53/github"
 	"github.com/stretchr/testify/assert"
 )