Repository: diggerhq/digger
Pull request #122 (2023-04-09 03:35:54)

Support Azure based locks
### Change log

#### Features
* Add ability to `Lock` & `Unlock` through **Azure** Storage Account tables
* Support *Shared Key* authentication
* Support *Connection string* authentication
* Support *Client secret* authentication
* Create table if it doesn't already exist
* Normalize lock name to work with `Digger`'s format: since Storage Account table doesn't accept `#` and `/` characters
* Provide meaningful errors to user at every step

#### Tests
* Add tests to cover operation done with all three authentication mechanisms
* Add test variations using a test table to test different way to authenticate
* Add a way to locally test without Azure storage account using `Azurite`
* Run checks before starting the tests to make sure everything's in order
* Add control to only run client secret test if using a real storage account
* Modify pipeline to install Azurite & start the services before running the tests

#### How does it work?
There is one mandatory environment variable the user will have to set, in order to use Azure based locks: `DIGGER_AZURE_AUTH_METHOD`, which can take one of the three values below:
* SHARED_KEY
* CONNECTION_STRING
* CLIENT_SECRET

Then, depending on the value of `DIGGER_AZURE_AUTH_METHOD`, the user will have to set other environment variables.

1. **SHARED_KEY**
    * `DIGGER_AZURE_SA_NAME`: Storage account name
    * `DIGGER_AZURE_SHARED_KEY`: shared key of the storage account
2. **CONNECTION_STRING**
    *  `DIGGER_AZURE_CONNECTION_STRING`: connection string
3. **CLIENT_SECRET**
    * `DIGGER_AZURE_TENANT_ID`: tenant id to use
    * `DIGGER_AZURE_CLIENT_ID`: client id of the service principal
    * `DIGGER_AZURE_CLIENT_SECRET`: secret of the service principal
    * `DIGGER_AZURE_SA_NAME`: storage account name

#### Running the tests
Since we need to use a real storage account to test our features, we have two options:
1. Use a real storage account
2. Use a local storage emulator called `Azurite` (that will emulate Azure Storage Account, but locally)

The tests have been set so we run tests against `Azurite` by default, unless otherwise specified. The only disadvantage of using `Azurite`, is that we cannot perform a `CLIENT_SECRET` authentication. This is why we voluntarily `Skip` client secret test cases when using `Azurite`.

To summarize the tests that are run:
* When `DIGGER_TEST_USE_REAL_SA` environment variable is set to "1":
    * Run Shared Key authentication tests
    * Run Connection string authentication tests
    * Run Client secret authentication tests
* When `DIGGER_TEST_USE_REAL_SA` environment variable is not set, or set to "0":
        * Run Shared Key authentication tests
        * Run Connection string authentication tests

> `DIGGER_TEST_USE_REAL_SA` is only used when testing, to skip or not Client secret test case since we are not using a real storage account.


Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index b21fc4f6..0e07282b 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -22,5 +22,11 @@ jobs:
       - name: Build
         run: go build -v ./cmd/digger
 
+      - name: Install Azurite
+        run: npm install -g azurite
+
+      - name: Start Azurite service
+        run: azurite --silent &
+
       - name: Test
         run: go test -v ./...
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index bf4a1e5d..bcc05484 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 .DS_Store
 venv/
 **/__pycache__/
+__azurite*
diff --git a/README.md b/README.md
index 084127b1..94d44d4c 100644
--- a/README.md
+++ b/README.md
@@ -32,9 +32,9 @@ Need a feature that's not listed? Book a [community feedback call](https://calen
 - ✅ 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)
 - ⌛ Configurable workflows. In addition to Atlantis-style (apply, then merge) also support "apply-only" and "no-lock"
 - ⌛ Gitlab Support. ETA April 2023
-- ⌛ Azure Support. Use Azure Cosmos DB for PR Locks. ETA April 2023
 - ⌛ Bitbucket Support
 - ⌛ Jenkins Support
 - ⌛ Digital Ocean Support
diff --git a/action.yml b/action.yml
index 0370e393..b5dc45e3 100644
--- a/action.yml
+++ b/action.yml
@@ -7,6 +7,43 @@ inputs:
     description: Name of the terraform workspace
     required: false
     default: default
+  setup-aws:
+    description: Setup AWS
+    required: false
+    default: 'false'
+  aws-access-key-id:
+    description: AWS access key id
+    required: false
+  aws-secret-access-key:
+    description: AWS secret access key
+    required: false
+  aws-region:
+    description: AWS region
+    required: false
+    default: us-east-1
+  setup-google-cloud:
+    description: Setup google cloud
+    required: false
+    default: 'false'
+  google-auth-credentials:
+      description: Google auth credentials
+      required: false
+  setup-terragrunt:
+    description: Setup terragrunt
+    required: false
+    default: 'false'
+  terragrunt-version:
+    description: Terragrunt version
+    required: false
+    default: v0.45.4
+  setup-terraform:
+    description: Setup terraform
+    required: false
+    default: 'false'
+  terraform-version:
+    description: Terraform version
+    required: false
+    default: v1.4.5
 
 outputs:
   output:
@@ -16,6 +53,43 @@ outputs:
 runs:
   using: composite
   steps:
+    - uses: actions/checkout@v3
+      if: github.event_name == 'issue_comment'
+      with:
+          ref: refs/pull/${{ github.event.issue.number }}/merge
+    - uses: actions/checkout@v3
+      if: github.event_name != 'issue_comment'
+
+    - id: google-auth
+      uses: google-github-actions/auth@v1
+      with:
+        credentials_json: ${{ inputs.google-auth-credentials }}
+      if: inputs.setup-google-cloud == 'true'
+
+    - name: 'Set up Cloud SDK'
+      uses: 'google-github-actions/setup-gcloud@v1'
+      if: inputs.setup-google-cloud == 'true'
+
+    - name: Configure Test AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ inputs.aws-access-key-id }}
+        aws-secret-access-key: ${{ inputs.aws-secret-access-key }}
+        aws-region: ${{ inputs.aws-region }}
+      if: inputs.setup-aws == 'true'
+
+    - name: Setup Terraform
+      uses: hashicorp/setup-terraform@v1
+      with:
+        terraform_version: ${{ inputs.terraform-version }}
+      if: inputs.setup-terraform == 'true'
+
+    - name: Setup Terragrunt
+      uses: autero1/action-terragrunt@v1.1.0
+      with:
+        terragrunt_version: ${{ inputs.terragrunt-version }}
+      if: inputs.setup-terragrunt == 'true'
+
     - name: build and run digger
       if: ${{ !startsWith(github.action_ref, 'v') }}
       shell: bash
diff --git a/cmd/digger/main.go b/cmd/digger/main.go
index 20b9f3ce..6392a231 100644
--- a/cmd/digger/main.go
+++ b/cmd/digger/main.go
@@ -11,7 +11,13 @@ import (
 )
 
 func main() {
-	utils.SendUsageRecord("", "log", "initialize")
+	githubRepositoryOwner := os.Getenv("GITHUB_REPOSITORY_OWNER")
+	if githubRepositoryOwner != "" {
+		utils.SendUsageRecord(githubRepositoryOwner, "log", "initialize")
+	} else {
+		utils.SendUsageRecord("", "log", "non github initialisation")
+	}
+
 	args := os.Args[1:]
 	if len(args) > 0 && args[0] == "version" {
 		fmt.Println(utils.GetVersion())
@@ -24,29 +30,29 @@ func main() {
 
 	ghToken := os.Getenv("GITHUB_TOKEN")
 	if ghToken == "" {
-		reportErrorAndExit("", "GITHUB_TOKEN is not defined", 1)
+		reportErrorAndExit(githubRepositoryOwner, "GITHUB_TOKEN is not defined", 1)
 	}
 
 	ghContext := os.Getenv("GITHUB_CONTEXT")
 	if ghContext == "" {
-		reportErrorAndExit("", "GITHUB_CONTEXT is not defined", 2)
+		reportErrorAndExit(githubRepositoryOwner, "GITHUB_CONTEXT is not defined", 2)
 	}
 
 	parsedGhContext, err := models.GetGitHubContext(ghContext)
 	if err != nil {
-		reportErrorAndExit("", fmt.Sprintf("Failed to parse GitHub context. %s", err), 3)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to parse GitHub context. %s", err), 3)
 	}
 	println("GitHub context parsed successfully")
 
 	diggerConfig, err := digger.NewDiggerConfig("")
 	if err != nil {
-		reportErrorAndExit(parsedGhContext.RepositoryOwner, fmt.Sprintf("Failed to read Digger config. %s", err), 4)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to read Digger config. %s", err), 4)
 	}
 	println("Digger config read successfully")
 
 	lock, err := utils.GetLock()
 	if err != nil {
-		reportErrorAndExit(parsedGhContext.RepositoryOwner, fmt.Sprintf("Failed to create lock provider. %s", err), 5)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to create lock provider. %s", err), 5)
 	}
 	println("Lock provider has been created successfully")
 
@@ -58,7 +64,7 @@ func main() {
 
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	if err != nil {
-		reportErrorAndExit(repoOwner, fmt.Sprintf("Failed to process GitHub event. %s", err), 6)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to process GitHub event. %s", err), 6)
 	}
 	println("GitHub event processed successfully")
 
@@ -67,23 +73,29 @@ func main() {
 		githubPrService.PublishComment(prNumber, reply)
 	}
 
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, diggerConfig.Workflows)
 	if err != nil {
-		reportErrorAndExit(repoOwner, fmt.Sprintf("Failed to convert GitHub event to commands. %s", err), 7)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to convert GitHub event to commands. %s", err), 7)
 	}
 	println("GitHub event converted to commands successfully")
 
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, "")
+	allAppliesSuccess, err := digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, "")
 	if err != nil {
-		reportErrorAndExit(repoOwner, fmt.Sprintf("Failed to run commands. %s", err), 8)
+		reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Failed to run commands. %s", err), 8)
 	}
+
+	if diggerConfig.AutoMerge && allAppliesSuccess {
+		digger.MergePullRequest(githubPrService, prNumber)
+		println("PR merged successfully")
+	}
+
 	println("Commands executed successfully")
 
-	reportErrorAndExit(repoOwner, "Digger finished successfully", 0)
+	reportErrorAndExit(githubRepositoryOwner, "Digger finished successfully", 0)
 
 	defer func() {
 		if r := recover(); r != nil {
-			reportErrorAndExit(repoOwner, fmt.Sprintf("Panic occurred. %s", r), 1)
+			reportErrorAndExit(githubRepositoryOwner, fmt.Sprintf("Panic occurred. %s", r), 1)
 		}
 	}()
 
diff --git a/go.mod b/go.mod
index 6160d138..db654613 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,10 @@ go 1.18
 
 require (
 	cloud.google.com/go/storage v1.30.1
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0
+	github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1
 	github.com/aws/aws-sdk-go v1.44.219
+	github.com/google/uuid v1.3.0
 	github.com/hashicorp/terraform-exec v0.18.1
 	github.com/stretchr/testify v1.8.2
 	gopkg.in/yaml.v2 v2.4.0
@@ -15,16 +18,21 @@ require (
 	cloud.google.com/go/compute v1.18.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/iam v0.12.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
 	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/google/uuid v1.3.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
 	github.com/googleapis/gax-go/v2 v2.7.1 // indirect
 	github.com/hashicorp/go-version v1.6.0 // indirect
 	github.com/hashicorp/terraform-json v0.15.0 // indirect
+	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/zclconf/go-cty v1.13.0 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	golang.org/x/crypto v0.5.0 // indirect
diff --git a/go.sum b/go.sum
index 2b596fc9..df3b2ac7 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,16 @@ cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB
 cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
 cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
 cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0 h1:sVPhtT2qjO86rTUaWMr4WoES4TkjGnzcioXcnHV9s5k=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0 h1:Yoicul8bnVdQrhDMTHxdEckRGX01XvwXDHUT9zYZ3k0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM=
+github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1 h1:bFa9IcjvrCber6gGgDAUZ+I2bO8J7s8JxXmu9fhi2ss=
+github.com/Azure/azure-sdk-for-go/sdk/data/aztables v1.0.1/go.mod h1:l3wvZkG9oW07GLBW5Cd0WwG5asOfJ8aqE8raUvNzLpk=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 h1:jp0dGvZ7ZK0mgqnTSClMxa5xuRL7NZgHameVYF6BurY=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 h1:WVsrXCnHlDDX8ls+tootqRE87/hL9S/g4ewig9RsD/c=
+github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
 github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ=
@@ -25,6 +35,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c=
 github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -33,6 +44,9 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
 github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
 github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
+github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
+github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -67,6 +81,7 @@ github.com/google/go-github/v50 v50.1.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S
 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=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -95,9 +110,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI=
+github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -163,6 +183,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/pkg/azure/storage_account.go b/pkg/azure/storage_account.go
new file mode 100644
index 00000000..eeef11fc
--- /dev/null
+++ b/pkg/azure/storage_account.go
@@ -0,0 +1,234 @@
+package azure
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+	"github.com/Azure/azure-sdk-for-go/sdk/data/aztables"
+)
+
+const (
+	TABLE_NAME = "DIGGERLOCK"
+)
+
+var (
+	SERVICE_URL_FORMAT = "https://%s.table.core.windows.net"
+)
+
+type StorageAccount struct {
+	tableClient *aztables.Client
+	svcClient   *aztables.ServiceClient
+}
+
+func NewStorageAccountLock() (*StorageAccount, error) {
+	authMethod := os.Getenv("DIGGER_AZURE_AUTH_METHOD")
+	if authMethod == "" {
+		return nil, fmt.Errorf("'DIGGER_AZURE_AUTH_METHOD' environment variable must be set to either 'SHARED_KEY' or 'CONNECTION_STRING' or 'CLIENT_SECRET'")
+	}
+
+	svcClient, err := getServiceClient(authMethod)
+	if err != nil {
+		return nil, err
+	}
+
+	sal := &StorageAccount{
+		svcClient:   svcClient,
+		tableClient: svcClient.NewClient(TABLE_NAME),
+	}
+
+	if err := sal.createTableIfNotExists(); err != nil {
+		return nil, fmt.Errorf("error while creating table: %v", err)
+	}
+	return sal, nil
+}
+
+func (sal *StorageAccount) Lock(transactionId int, resource string) (bool, error) {
+	resource = normalizeResourceName(resource)
+	entity := aztables.EDMEntity{
+		Properties: map[string]interface{}{
+			"transaction_id": transactionId,
+		},
+		Entity: aztables.Entity{
+			PartitionKey: "digger",
+			RowKey:       resource,
+		},
+	}
+	b, err := json.Marshal(entity)
+	if err != nil {
+		return false, fmt.Errorf("could not marshall entity: %v", err)
+	}
+
+	_, err = sal.tableClient.AddEntity(context.Background(), b, nil)
+	if err != nil {
+		if strings.Contains(err.Error(), "EntityAlreadyExists") {
+			return false, nil
+		}
+		return false, fmt.Errorf("could not add entity: \n%v", err)
+	}
+
+	return true, nil
+}
+
+func (sal *StorageAccount) Unlock(resource string) (bool, error) {
+	resource = normalizeResourceName(resource)
+	_, err := sal.tableClient.DeleteEntity(context.Background(), "digger", resource, nil)
+	if err != nil {
+		return false, fmt.Errorf("could not delete lock: %v", err)
+	}
+
+	return true, nil
+}
+
+func (sal *StorageAccount) GetLock(resource string) (*int, error) {
+	resource = normalizeResourceName(resource)
+	filterQuery := fmt.Sprintf("PartitionKey eq 'digger' and RowKey eq '%s'", resource)
+	selectQuery := "RowKey,PartitionKey,transaction_id"
+	listOpts := aztables.ListEntitiesOptions{
+		Filter: &filterQuery,
+		Select: &selectQuery,
+	}
+
+	entitiesPager := sal.tableClient.NewListEntitiesPager(&listOpts)
+	for entitiesPager.More() {
+		res, err := entitiesPager.NextPage(context.Background())
+		if err != nil {
+			return nil, fmt.Errorf("could not retrieve the entities: %v", err)
+		}
+
+		for _, e := range res.Entities {
+			var entity aztables.EDMEntity
+			err := json.Unmarshal(e, &entity)
+			if err != nil {
+				return nil, fmt.Errorf("could not unmarshall entity: %v", err)
+			}
+
+			transactionId := int(entity.Properties["transaction_id"].(int32))
+			return &transactionId, nil
+		}
+	}
+
+	// Lock doesn't exist
+	return nil, nil
+}
+
+func getServiceClient(authMethod string) (*aztables.ServiceClient, error) {
+	if authMethod == "SHARED_KEY" {
+		return getSharedKeySvcClient()
+	}
+
+	if authMethod == "CONNECTION_STRING" {
+		return getConnStringSvcClient()
+	}
+
+	if authMethod == "CLIENT_SECRET" {
+		return getClientSecretSvcClient()
+	}
+
+	return nil, fmt.Errorf("could not initialize service client, because no valid authentication method was found")
+}
+
+func getSharedKeySvcClient() (*aztables.ServiceClient, error) {
+	key := os.Getenv("DIGGER_AZURE_SHARED_KEY")
+	saName := os.Getenv("DIGGER_AZURE_SA_NAME")
+	if saName == "" || key == "" {
+		return nil, fmt.Errorf("you must set 'DIGGER_AZURE_SA_NAME' and 'DIGGER_AZURE_SHARED_KEY' environment variable when using shared key authentication")
+	}
+
+	sharedCreds, err := aztables.NewSharedKeyCredential(saName, key)
+	if err != nil {
+		return nil, fmt.Errorf("could not create shared key credentials: %v", err)
+	}
+
+	serviceURL := getServiceURL(saName)
+	svcClient, err := aztables.NewServiceClientWithSharedKey(serviceURL, sharedCreds, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not create service client with shared key authentication: %v", err)
+	}
+	return svcClient, nil
+}
+
+func getConnStringSvcClient() (*aztables.ServiceClient, error) {
+	connStr := os.Getenv("DIGGER_AZURE_CONNECTION_STRING")
+	if connStr == "" {
+		return nil, fmt.Errorf("you must set 'DIGGER_AZURE_CONNECTION_STRING' when using connection string authentication")
+	}
+
+	svcClient, err := aztables.NewServiceClientFromConnectionString(connStr, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not create service client with connection string authentication: %v", err)
+	}
+	return svcClient, err
+}
+
+func getClientSecretSvcClient() (*aztables.ServiceClient, error) {
+	tenantId := os.Getenv("DIGGER_AZURE_TENANT_ID")
+	clientId := os.Getenv("DIGGER_AZURE_CLIENT_ID")
+	secret := os.Getenv("DIGGER_AZURE_CLIENT_SECRET")
+	saName := os.Getenv("DIGGER_AZURE_SA_NAME")
+
+	if clientId == "" || secret == "" || tenantId == "" || saName == "" {
+		return nil, fmt.Errorf("you must set 'DIGGER_AZURE_CLIENT_ID' and 'DIGGER_AZURE_CLIENT_SECRET' and 'DIGGER_AZURE_TENANT_ID' and 'DIGGER_AZURE_SA_NAME' when using client secret authentication")
+	}
+
+	serviceURL := getServiceURL(saName)
+	cred, err := azidentity.NewClientSecretCredential(tenantId, clientId, secret, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not create create client secret credential: %v", err)
+	}
+
+	svcClient, err := aztables.NewServiceClient(serviceURL, cred, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not create service client with client secret authentication: %v", err)
+	}
+	return svcClient, nil
+}
+
+func (sal *StorageAccount) createTableIfNotExists() error {
+	exists, err := sal.isTableExists(TABLE_NAME)
+	if err != nil {
+		return err
+	}
+
+	if exists {
+		return nil
+	}
+
+	// Table doesn't exist, we create it
+	_, err = sal.tableClient.CreateTable(context.TODO(), nil)
+	if err != nil {
+		return fmt.Errorf("could not create table: %v", err)
+	}
+
+	return nil
+}
+
+func (sal *StorageAccount) isTableExists(table string) (bool, error) {
+	tablesPager := sal.svcClient.NewListTablesPager(nil)
+	for tablesPager.More() {
+		res, err := tablesPager.NextPage(context.Background())
+		if err != nil {
+			return false, fmt.Errorf("could not retrieve the tables: %v", err)
+		}
+
+		for _, t := range res.Tables {
+			if *t.Name == table {
+				return true, nil
+			}
+		}
+	}
+
+	return false, nil
+}
+
+func getServiceURL(saName string) string {
+	return fmt.Sprintf(SERVICE_URL_FORMAT, saName)
+}
+
+func normalizeResourceName(resourceName string) string {
+	resourceName = strings.ReplaceAll(resourceName, "/", "-")
+	return strings.ReplaceAll(resourceName, "#", "-")
+}
diff --git a/pkg/digger/digger.go b/pkg/digger/digger.go
index 8000e665..647571eb 100644
--- a/pkg/digger/digger.go
+++ b/pkg/digger/digger.go
@@ -47,9 +47,12 @@ func ProcessGitHubEvent(ghEvent models.Event, diggerConfig *DiggerConfig, prMana
 	return impactedProjects, prNumber, nil
 }
 
-func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string, repoName string, eventName string, prNumber int, diggerConfig *DiggerConfig, prManager github.PullRequestManager, lock utils.Lock, workingDir string) error {
+func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string, repoName string, eventName string, prNumber int, diggerConfig *DiggerConfig, prManager github.PullRequestManager, lock utils.Lock, workingDir string) (bool, error) {
 	lockAcquisitionSuccess := true
+	allAppliesSuccess := true
+	appliesPerProject := make(map[string]bool)
 	for _, projectCommands := range commandsPerProject {
+		appliesPerProject[projectCommands.ProjectName] = false
 		for _, command := range projectCommands.Commands {
 			projectLock := &utils.ProjectLockImpl{
 				InternalLock: lock,
@@ -66,6 +69,8 @@ func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string
 				projectCommands.ProjectDir,
 				repoName,
 				projectCommands.Terragrunt,
+				projectCommands.ApplyStage,
+				projectCommands.PlanStage,
 				prManager,
 				projectLock,
 				diggerConfig,
@@ -73,10 +78,23 @@ func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string
 			switch command {
 			case "digger plan":
 				utils.SendUsageRecord(repoOwner, eventName, "plan")
-				diggerExecutor.Plan(prNumber)
+				prManager.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/plan")
+				err := diggerExecutor.Plan(prNumber)
+				if err != nil {
+					prManager.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/plan")
+				} else {
+					prManager.SetStatus(prNumber, "success", projectCommands.ProjectName+"/plan")
+				}
 			case "digger apply":
 				utils.SendUsageRecord(repoName, eventName, "apply")
-				diggerExecutor.Apply(prNumber)
+				prManager.SetStatus(prNumber, "pending", projectCommands.ProjectName+"/apply")
+				err := diggerExecutor.Apply(prNumber)
+				if err != nil {
+					prManager.SetStatus(prNumber, "failure", projectCommands.ProjectName+"/apply")
+				} else {
+					prManager.SetStatus(prNumber, "success", projectCommands.ProjectName+"/apply")
+					appliesPerProject[projectCommands.ProjectName] = true
+				}
 			case "digger unlock":
 				utils.SendUsageRecord(repoOwner, eventName, "unlock")
 				diggerExecutor.Unlock(prNumber)
@@ -90,7 +108,39 @@ func RunCommandsPerProject(commandsPerProject []ProjectCommand, repoOwner string
 	if !lockAcquisitionSuccess {
 		os.Exit(1)
 	}
-	return nil
+	for _, success := range appliesPerProject {
+		if !success {
+			allAppliesSuccess = false
+		}
+	}
+	return allAppliesSuccess, nil
+}
+
+func MergePullRequest(githubPrService github.PullRequestManager, prNumber int) {
+	combinedStatus, err := githubPrService.GetCombinedPullRequestStatus(prNumber)
+
+	if err != nil {
+		log.Fatalf("failed to get combined status, %v", err)
+	}
+
+	if combinedStatus != "success" {
+		log.Fatalf("PR is not mergeable. Status: %v", combinedStatus)
+	}
+
+	prIsMergeable, mergeableState, err := githubPrService.IsMergeable(prNumber)
+
+	if err != nil {
+		log.Fatalf("failed to check if PR is mergeable, %v", err)
+	}
+
+	if !prIsMergeable {
+		log.Fatalf("PR is not mergeable. State: %v", mergeableState)
+	}
+
+	err = githubPrService.MergePullRequest(prNumber)
+	if err != nil {
+		log.Fatalf("failed to merge PR, %v", err)
+	}
 }
 
 func GetGitHubContext(ghContext string) (*models.Github, error) {
@@ -108,22 +158,30 @@ type ProjectCommand struct {
 	ProjectWorkspace string
 	Terragrunt       bool
 	Commands         []string
+	ApplyStage       Stage
+	PlanStage        Stage
 }
 
-func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project) ([]ProjectCommand, error) {
+func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project, workflows map[string]Workflow) ([]ProjectCommand, error) {
 	commandsPerProject := make([]ProjectCommand, 0)
 
 	switch event.(type) {
 	case models.PullRequestEvent:
 		event := event.(models.PullRequestEvent)
 		for _, project := range impactedProjects {
+			workflow, ok := workflows[project.Workflow]
+			if !ok {
+				workflow = *defaultWorkflow()
+			}
 			if event.Action == "closed" && event.PullRequest.Merged && event.PullRequest.Base.Ref == event.Repository.DefaultBranch {
 				commandsPerProject = append(commandsPerProject, ProjectCommand{
 					ProjectName:      project.Name,
 					ProjectDir:       project.Dir,
 					ProjectWorkspace: project.Workspace,
 					Terragrunt:       project.Terragrunt,
-					Commands:         project.WorkflowConfiguration.OnCommitToDefault,
+					Commands:         workflow.Configuration.OnCommitToDefault,
+					ApplyStage:       *workflow.Apply,
+					PlanStage:        *workflow.Plan,
 				})
 			} else if event.Action == "opened" || event.Action == "reopened" || event.Action == "synchronize" {
 				commandsPerProject = append(commandsPerProject, ProjectCommand{
@@ -131,7 +189,9 @@ func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project
 					ProjectDir:       project.Dir,
 					ProjectWorkspace: project.Workspace,
 					Terragrunt:       project.Terragrunt,
-					Commands:         project.WorkflowConfiguration.OnPullRequestPushed,
+					Commands:         workflow.Configuration.OnPullRequestPushed,
+					ApplyStage:       *workflow.Apply,
+					PlanStage:        *workflow.Plan,
 				})
 			} else if event.Action == "closed" {
 				commandsPerProject = append(commandsPerProject, ProjectCommand{
@@ -139,7 +199,9 @@ func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project
 					ProjectDir:       project.Dir,
 					ProjectWorkspace: project.Workspace,
 					Terragrunt:       project.Terragrunt,
-					Commands:         project.WorkflowConfiguration.OnPullRequestClosed,
+					Commands:         workflow.Configuration.OnPullRequestPushed,
+					ApplyStage:       *workflow.Apply,
+					PlanStage:        *workflow.Plan,
 				})
 			}
 		}
@@ -151,6 +213,10 @@ func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project
 		for _, command := range supportedCommands {
 			if strings.Contains(event.Comment.Body, command) {
 				for _, project := range impactedProjects {
+					workflow, ok := workflows[project.Workflow]
+					if !ok {
+						workflow = *defaultWorkflow()
+					}
 					workspace := project.Workspace
 					workspaceOverride, err := parseWorkspace(event.Comment.Body)
 					if err != nil {
@@ -165,6 +231,8 @@ func ConvertGithubEventToCommands(event models.Event, impactedProjects []Project
 						ProjectWorkspace: workspace,
 						Terragrunt:       project.Terragrunt,
 						Commands:         []string{command},
+						ApplyStage:       *workflow.Apply,
+						PlanStage:        *workflow.Plan,
 					})
 				}
 			}
@@ -211,6 +279,8 @@ type DiggerExecutor struct {
 	projectDir   string
 	repoName     string
 	terragrunt   bool
+	applyStage   Stage
+	planStage    Stage
 	prManager    github.PullRequestManager
 	lock         utils.ProjectLock
 	configDigger *DiggerConfig
@@ -220,7 +290,7 @@ func (d DiggerExecutor) LockId() string {
 	return d.repoOwner + "/" + d.repoName + "#" + d.projectName
 }
 
-func (d DiggerExecutor) Plan(prNumber int) {
+func (d DiggerExecutor) Plan(prNumber int) error {
 
 	var terraformExecutor terraform.TerraformExecutor
 
@@ -232,43 +302,85 @@ func (d DiggerExecutor) Plan(prNumber int) {
 
 	res, err := d.lock.Lock(d.LockId(), prNumber)
 	if err != nil {
-		log.Fatalf("Error locking project: %v", err)
+		return fmt.Errorf("Error locking project: %v", err)
 	}
 	if res {
-		isNonEmptyPlan, stdout, stderr, err := terraformExecutor.Plan()
+		var initArgs []string
+		var planArgs []string
+
+		for _, step := range d.planStage.Steps {
+			if step.Action == "init" {
+				initArgs = append(initArgs, step.ExtraArgs...)
+			}
+			if step.Action == "plan" {
+				planArgs = append(planArgs, step.ExtraArgs...)
+			}
+		}
+		isNonEmptyPlan, stdout, stderr, err := terraformExecutor.Plan(initArgs, planArgs)
 
 		if err != nil {
-			log.Fatalf("Error executing plan: %v", err)
+			return fmt.Errorf("error executing plan: %v", err)
 		}
 		plan := cleanupTerraformPlan(isNonEmptyPlan, err, stdout, stderr)
 		comment := "Plan for **" + d.LockId() + "**\n" + plan
 		d.prManager.PublishComment(prNumber, comment)
 	}
-
+	return nil
 }
 
-func (d DiggerExecutor) Apply(prNumber int) {
-	var terraformExecutor terraform.TerraformExecutor
-
-	if d.terragrunt {
-		terraformExecutor = terraform.Terragrunt{WorkingDir: path.Join(d.workingDir, d.projectDir)}
-	} else {
-		terraformExecutor = terraform.Terraform{WorkingDir: path.Join(d.workingDir, d.projectDir), Workspace: d.workspace}
+func (d DiggerExecutor) Apply(prNumber int) error {
+	isMergeable, _, err := d.prManager.IsMergeable(prNumber)
+	if err != nil {
+		return fmt.Errorf("error validating is PR is mergeable: %v", err)
 	}
 
-	if res, _ := d.lock.Lock(d.LockId(), prNumber); res {
-		stdout, stderr, err := terraformExecutor.Apply()
-		applyOutput := cleanupTerraformApply(true, err, stdout, stderr)
-		comment := "Apply for **" + d.LockId() + "**\n" + applyOutput
+	if !isMergeable {
+		comment := "Cannot perform Apply since the PR is not currently mergeable."
 		d.prManager.PublishComment(prNumber, comment)
-		d.lock.Unlock(d.LockId(), prNumber)
-	}
+	} else {
+		var terraformExecutor terraform.TerraformExecutor
 
+		if d.terragrunt {
+			terraformExecutor = terraform.Terragrunt{WorkingDir: path.Join(d.workingDir, d.projectDir)}
+		} else {
+			terraformExecutor = terraform.Terraform{WorkingDir: path.Join(d.workingDir, d.projectDir), Workspace: d.workspace}
+		}
+
+		if res, _ := d.lock.Lock(d.LockId(), prNumber); res {
+			var initArgs []string
+			var applyArgs []string
+
+			for _, step := range d.applyStage.Steps {
+				if step.Action == "init" {
+					initArgs = append(initArgs, step.ExtraArgs...)
+				}
+				if step.Action == "apply" {
+					applyArgs = append(applyArgs, step.ExtraArgs...)
+				}
+			}
+			stdout, stderr, err := terraformExecutor.Apply(initArgs, applyArgs)
+			applyOutput := cleanupTerraformApply(true, err, stdout, stderr)
+			comment := "Apply for **" + d.LockId() + "**\n" + applyOutput
+			d.prManager.PublishComment(prNumber, comment)
+			if err == nil {
+				_, err := d.lock.Unlock(d.LockId(), prNumber)
+				if err != nil {
+					return fmt.Errorf("error unlocking project: %v", err)
+				}
+			} else {
+				d.prManager.PublishComment(prNumber, "Error during applying. Project lock will persist")
+			}
+		}
+	}
+	return nil
 }
 
 func (d DiggerExecutor) Unlock(prNumber int) {
-	d.lock.ForceUnlock(d.LockId(), prNumber)
-
+	err := d.lock.ForceUnlock(d.LockId(), prNumber)
+	if err != nil {
+		fmt.Printf("%v\n", err)
+		os.Exit(1)
+	}
 }
 
 func (d DiggerExecutor) Lock(prNumber int) bool {
@@ -337,3 +449,33 @@ func CheckIfHelpComment(event models.Event) bool {
 	}
 	return false
 }
+
+func defaultWorkflow() *Workflow {
+	return &Workflow{
+		Configuration: &WorkflowConfiguration{
+			OnCommitToDefault:   []string{"digger unlock"},
+			OnPullRequestPushed: []string{"digger plan"},
+			OnPullRequestClosed: []string{"digger unlock"},
+		},
+		Plan: &Stage{
+			Steps: []Step{
+				{
+					Action: "init", ExtraArgs: []string{},
+				},
+				{
+					Action: "plan", ExtraArgs: []string{},
+				},
+			},
+		},
+		Apply: &Stage{
+			Steps: []Step{
+				{
+					Action: "init", ExtraArgs: []string{},
+				},
+				{
+					Action: "apply", ExtraArgs: []string{},
+				},
+			},
+		},
+	}
+}
diff --git a/pkg/digger/digger_config.go b/pkg/digger/digger_config.go
index 91fae669..8bc7b4b9 100644
--- a/pkg/digger/digger_config.go
+++ b/pkg/digger/digger_config.go
@@ -3,12 +3,11 @@ package digger
 import (
 	"errors"
 	"fmt"
+	"gopkg.in/yaml.v3"
 	"os"
 	"path"
 	"path/filepath"
 	"strings"
-
-	"gopkg.in/yaml.v2"
 )
 
 type WorkflowConfiguration struct {
@@ -17,16 +16,34 @@ type WorkflowConfiguration struct {
 	OnCommitToDefault   []string `yaml:"on_commit_to_default"`
 }
 
+type DiggerConfigYaml struct {
+	Projects  []Project           `yaml:"projects"`
+	AutoMerge bool                `yaml:"auto_merge"`
+	Workflows map[string]Workflow `yaml:"workflows"`
+}
+
 type DiggerConfig struct {
-	Projects []Project `yaml:"projects"`
+	Projects  []Project
+	AutoMerge bool
+	Workflows map[string]Workflow
 }
 
 type Project struct {
-	Name                  string                `yaml:"name"`
-	Dir                   string                `yaml:"dir"`
-	Workspace             string                `yaml:"workspace"`
-	Terragrunt            bool                  `yaml:"terragrunt"`
-	WorkflowConfiguration WorkflowConfiguration `yaml:"workflow_configuration"`
+	Name       string `yaml:"name"`
+	Dir        string `yaml:"dir"`
+	Workspace  string `yaml:"workspace"`
+	Terragrunt bool   `yaml:"terragrunt"`
+	Workflow   string `yaml:"workflow"`
+}
+
+type Stage struct {
+	Steps []Step `yaml:"steps"`
+}
+
+type Workflow struct {
+	Plan          *Stage                 `yaml:"plan,omitempty"`
+	Apply         *Stage                 `yaml:"apply,omitempty"`
+	Configuration *WorkflowConfiguration `yaml:"workflow_configuration"`
 }
 
 var ErrDiggerConfigConflict = errors.New("more than one digger config file detected, please keep either 'digger.yml' or 'digger.yaml'")
@@ -36,11 +53,7 @@ func (p *Project) UnmarshalYAML(unmarshal func(interface{}) error) error {
 	raw := rawProject{
 		Workspace:  "default",
 		Terragrunt: false,
-		WorkflowConfiguration: WorkflowConfiguration{
-			OnPullRequestPushed: []string{"digger plan"},
-			OnPullRequestClosed: []string{"digger unlock"},
-			OnCommitToDefault:   []string{"digger apply"},
-		},
+		Workflow:   "default",
 	}
 	if err := unmarshal(&raw); err != nil {
 		return err
@@ -50,8 +63,41 @@ func (p *Project) UnmarshalYAML(unmarshal func(interface{}) error) error {
 
 }
 
+type Step struct {
+	Action    string
+	ExtraArgs []string `yaml:"extra_args,omitempty"`
+}
+
+func (s *Step) UnmarshalYAML(value *yaml.Node) error {
+	if value.Kind == yaml.ScalarNode {
+		return value.Decode(&s.Action)
+	}
+
+	var stepMap map[string]map[string][]string
+	if err := value.Decode(&stepMap); err != nil {
+		return err
+	}
+
+	for k, v := range stepMap {
+		s.Action = k
+		s.ExtraArgs = v["extra_args"]
+		break
+	}
+	return nil
+}
+
+func ConvertDiggerYamlToConfig(diggerYaml *DiggerConfigYaml) (*DiggerConfig, error) {
+	var diggerConfig DiggerConfig
+
+	diggerConfig.AutoMerge = diggerYaml.AutoMerge
+	diggerConfig.Workflows = diggerYaml.Workflows
+	diggerConfig.Projects = diggerYaml.Projects
+
+	return &diggerConfig, nil
+}
+
 func NewDiggerConfig(workingDir string) (*DiggerConfig, error) {
-	config := &DiggerConfig{}
+	config := &DiggerConfigYaml{}
 	fileName, err := retrieveConfigFile(workingDir)
 	if err != nil {
 		if errors.Is(err, ErrDiggerConfigConflict) {
@@ -63,14 +109,48 @@ func NewDiggerConfig(workingDir string) (*DiggerConfig, error) {
 	if err != nil {
 		config.Projects = make([]Project, 1)
 		config.Projects[0] = defaultProject()
-		return config, nil
+		config.Workflows = make(map[string]Workflow)
+		config.Workflows["default"] = Workflow{
+			Plan: &Stage{
+				Steps: []Step{{
+					Action:    "init",
+					ExtraArgs: []string{},
+				}, {
+					"plan",
+					[]string{},
+				}},
+			},
+			Apply: &Stage{
+				Steps: []Step{{
+					Action:    "init",
+					ExtraArgs: []string{},
+				}, {
+					"apply",
+					[]string{},
+				}},
+			},
+			Configuration: &WorkflowConfiguration{
+				OnPullRequestPushed: []string{"digger plan"},
+				OnPullRequestClosed: []string{"digger unlock"},
+				OnCommitToDefault:   []string{"digger apply"},
+			},
+		}
+		c, err := ConvertDiggerYamlToConfig(config)
+		if err != nil {
+			return nil, err
+		}
+		return c, nil
 	}
 
 	if err := yaml.Unmarshal(data, config); err != nil {
 		return nil, fmt.Errorf("error parsing '%s': %v", fileName, err)
 	}
 
-	return config, nil
+	c, err := ConvertDiggerYamlToConfig(config)
+	if err != nil {
+		return nil, err
+	}
+	return c, nil
 }
 
 func defaultProject() Project {
@@ -79,11 +159,8 @@ func defaultProject() Project {
 		Dir:        ".",
 		Workspace:  "default",
 		Terragrunt: false,
-		WorkflowConfiguration: WorkflowConfiguration{
-			OnPullRequestPushed: []string{"digger plan"},
-			OnPullRequestClosed: []string{"digger unlock"},
-			OnCommitToDefault:   []string{"digger apply"},
-		}}
+		Workflow:   "default",
+	}
 }
 
 func (c *DiggerConfig) GetProject(projectName string) *Project {
@@ -129,12 +206,29 @@ func (c *DiggerConfig) GetDirectory(projectName string) string {
 	return project.Dir
 }
 
+func (c *DiggerConfig) GetWorkflow(workflowName string) *Workflow {
+	workflows := c.Workflows
+
+	workflow, ok := workflows[workflowName]
+	if !ok {
+		return nil
+	}
+	return &workflow
+
+}
+
 func (c *DiggerConfig) GetWorkflowConfiguration(projectName string) WorkflowConfiguration {
 	project := c.GetProject(projectName)
+	workflows := c.Workflows
 	if project == nil {
 		return WorkflowConfiguration{}
 	}
-	return project.WorkflowConfiguration
+	workflow, ok := workflows[project.Workflow]
+
+	if !ok {
+		return WorkflowConfiguration{}
+	}
+	return *workflow.Configuration
 }
 
 type File struct {
diff --git a/pkg/github/github.go b/pkg/github/github.go
index 98d6ee63..4e737efe 100644
--- a/pkg/github/github.go
+++ b/pkg/github/github.go
@@ -25,6 +25,10 @@ type GithubPullRequestService struct {
 type PullRequestManager interface {
 	GetChangedFiles(prNumber int) ([]string, error)
 	PublishComment(prNumber int, comment string)
+	SetStatus(prNumber int, status string, statusContext string) error
+	GetCombinedPullRequestStatus(prNumber int) (string, error)
+	MergePullRequest(prNumber int) error
+	IsMergeable(prNumber int) (bool, string, error)
 }
 
 func (svc *GithubPullRequestService) GetChangedFiles(prNumber int) ([]string, error) {
@@ -47,3 +51,53 @@ func (svc *GithubPullRequestService) PublishComment(prNumber int, comment string
 		log.Fatalf("error publishing comment: %v", err)
 	}
 }
+
+func (svc *GithubPullRequestService) SetStatus(prNumber int, status string, statusContext string) error {
+	pr, _, err := svc.Client.PullRequests.Get(context.Background(), svc.Owner, svc.RepoName, prNumber)
+	if err != nil {
+		log.Fatalf("error getting pull request: %v", err)
+	}
+
+	_, _, err = svc.Client.Repositories.CreateStatus(context.Background(), svc.Owner, svc.RepoName, *pr.Head.SHA, &github.RepoStatus{
+		State:       &status,
+		Context:     &statusContext,
+		Description: &statusContext,
+	})
+	return err
+}
+
+func (svc *GithubPullRequestService) GetCombinedPullRequestStatus(prNumber int) (string, error) {
+	pr, _, err := svc.Client.PullRequests.Get(context.Background(), svc.Owner, svc.RepoName, prNumber)
+	if err != nil {
+		log.Fatalf("error getting pull request: %v", err)
+	}
+
+	statuses, _, err := svc.Client.Repositories.GetCombinedStatus(context.Background(), svc.Owner, svc.RepoName, pr.Head.GetSHA(), nil)
+	if err != nil {
+		log.Fatalf("error getting combined status: %v", err)
+	}
+
+	return *statuses.State, nil
+}
+
+func (svc *GithubPullRequestService) MergePullRequest(prNumber int) error {
+	pr, _, err := svc.Client.PullRequests.Get(context.Background(), svc.Owner, svc.RepoName, prNumber)
+	if err != nil {
+		log.Fatalf("error getting pull request: %v", err)
+	}
+
+	_, _, err = svc.Client.PullRequests.Merge(context.Background(), svc.Owner, svc.RepoName, prNumber, "auto-merge", &github.PullRequestOptions{
+		MergeMethod: "squash",
+		SHA:         pr.Head.GetSHA(),
+	})
+	return err
+}
+
+func (svc *GithubPullRequestService) IsMergeable(prNumber int) (bool, string, error) {
+	pr, _, err := svc.Client.PullRequests.Get(context.Background(), svc.Owner, svc.RepoName, prNumber)
+	if err != nil {
+		log.Fatalf("error getting pull request: %v", err)
+	}
+
+	return pr.GetMergeable(), pr.GetMergeableState(), nil
+}
diff --git a/pkg/github/pullrequestmanager_mock.go b/pkg/github/pullrequestmanager_mock.go
index 8ca420e0..69979f4a 100644
--- a/pkg/github/pullrequestmanager_mock.go
+++ b/pkg/github/pullrequestmanager_mock.go
@@ -12,3 +12,23 @@ func (mockGithubPullrequestManager *MockGithubPullrequestManager) GetChangedFile
 func (mockGithubPullrequestManager *MockGithubPullrequestManager) PublishComment(prNumber int, comment string) {
 	mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "PublishComment")
 }
+
+func (mockGithubPullrequestManager *MockGithubPullrequestManager) SetStatus(prNumber int, status string, statusContext string) error {
+	mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "SetStatus")
+	return nil
+}
+
+func (mockGithubPullrequestManager *MockGithubPullrequestManager) GetCombinedPullRequestStatus(prNumber int) (string, error) {
+	mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "GetCombinedPullRequestStatus")
+	return "", nil
+}
+
+func (mockGithubPullrequestManager *MockGithubPullrequestManager) MergePullRequest(prNumber int) error {
+	mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "MergePullRequest")
+	return nil
+}
+
+func (mockGithubPullrequestManager *MockGithubPullrequestManager) IsMergeable(prNumber int) (bool, string, error) {
+	mockGithubPullrequestManager.commands = append(mockGithubPullrequestManager.commands, "IsMergeable")
+	return true, "", nil
+}
diff --git a/pkg/terraform/tf.go b/pkg/terraform/tf.go
index ccf268c7..d71b30ad 100644
--- a/pkg/terraform/tf.go
+++ b/pkg/terraform/tf.go
@@ -2,18 +2,16 @@ package terraform
 
 import (
 	"bytes"
-	"context"
 	"fmt"
-	"github.com/hashicorp/terraform-exec/tfexec"
 	"io"
-	"log"
 	"os"
 	"os/exec"
+	"strings"
 )
 
 type TerraformExecutor interface {
-	Apply() (string, string, error)
-	Plan() (bool, string, string, error)
+	Apply([]string, []string) (string, string, error)
+	Plan([]string, []string) (bool, string, string, error)
 }
 
 type Terragrunt struct {
@@ -25,17 +23,33 @@ type Terraform struct {
 	Workspace  string
 }
 
-func (terragrunt Terragrunt) Apply() (string, string, error) {
-	return terragrunt.runTerragruntCommand("apply")
+func (terragrunt Terragrunt) Apply(initParams []string, applyParams []string) (string, string, error) {
+	stdout, stderr, err := terragrunt.runTerragruntCommand("init", initParams...)
+	if err != nil {
+		return stdout, stderr, err
+	}
+	applyParams = append(applyParams, "--auto-approve")
+	applyParams = append(applyParams, "--terragrunt-non-interactive")
+	stdout, stderr, err = terragrunt.runTerragruntCommand("apply", applyParams...)
+	return stdout, stderr, err
 }
 
-func (terragrunt Terragrunt) Plan() (bool, string, string, error) {
-	stdout, stderr, err := terragrunt.runTerragruntCommand("plan")
+func (terragrunt Terragrunt) Plan(initParams []string, planParams []string) (bool, string, string, error) {
+	stdout, stderr, err := terragrunt.runTerragruntCommand("init", initParams...)
+	if err != nil {
+		return false, stdout, stderr, err
+	}
+
+	stdout, stderr, err = terragrunt.runTerragruntCommand("plan", planParams...)
 	return true, stdout, stderr, err
 }
 
-func (terragrunt Terragrunt) runTerragruntCommand(command string) (string, string, error) {
-	cmd := exec.Command("terragrunt", command, "--terragrunt-working-dir", terragrunt.WorkingDir)
+func (terragrunt Terragrunt) runTerragruntCommand(command string, arg ...string) (string, string, error) {
+	args := []string{command}
+	args = append(args, arg...)
+	args = append(args, "--terragrunt-working-dir", terragrunt.WorkingDir)
+	cmd := exec.Command("terragrunt", args...)
+
 	env := os.Environ()
 	env = append(env, "TF_CLI_ARGS=-no-color")
 	env = append(env, "TF_IN_AUTOMATION=true")
@@ -55,48 +69,54 @@ func (terragrunt Terragrunt) runTerragruntCommand(command string) (string, strin
 	return stdout.String(), stderr.String(), err
 }
 
-func (terraform Terraform) Apply() (string, string, error) {
-	println("digger apply")
-	execDir := "terraform"
-	tf, err := tfexec.NewTerraform(terraform.WorkingDir, execDir)
+func (tf Terraform) Apply(initParams []string, applyParams []string) (string, string, error) {
+	initParams = append(append(append(initParams, "-upgrade=true"), "-input=false"), "-no-color")
+	_, _, _, err := tf.runTerraformCommand("init", initParams...)
 	if err != nil {
-		return "", "", fmt.Errorf("error while initializing terraform: %s", err)
+		return "", "", err
 	}
+	workspace, _, _, err := tf.runTerraformCommand("workspace", "show")
 
-	stdout := &StdWriter{[]byte{}, true}
-	//stderr := &StdWriter{[]byte{}, true}
-	tf.SetStdout(stdout)
-	//tf.SetStderr(stderr)
-	tf.SetStderr(os.Stderr)
-
-	err = tf.Init(context.Background(), tfexec.Upgrade(false))
 	if err != nil {
-		println("terraform init failed.")
-		return stdout.GetString(), "", fmt.Errorf("terraform init failed. %s", err)
+		return "", "", err
 	}
-	currentWorkspace, err := tf.WorkspaceShow(context.Background())
 
+	if strings.TrimSpace(workspace) != tf.Workspace {
+		_, _, _, err = tf.runTerraformCommand("workspace", "new", tf.Workspace)
+		if err != nil {
+			return "", "", err
+		}
+	}
+	applyParams = append(append(append(applyParams, "-input=false"), "-no-color"), "-auto-approve")
+	stdout, stderr, _, err := tf.runTerraformCommand("apply", applyParams...)
 	if err != nil {
-		log.Printf("terraform workspace show failed. workspace: %v . dir: %v", terraform.Workspace, terraform.WorkingDir)
-		return stdout.GetString(), "", fmt.Errorf("terraform show failed. %s", err)
+		return "", "", err
 	}
+	return stdout, stderr, nil
+}
 
-	if currentWorkspace != terraform.Workspace {
-		err = tf.WorkspaceNew(context.Background(), terraform.Workspace)
+func (tf Terraform) runTerraformCommand(command string, arg ...string) (string, string, int, error) {
+	args := []string{"-chdir=" + tf.WorkingDir}
+	args = append(args, command)
+	args = append(args, arg...)
+	fmt.Printf("Running terraform %v", args)
 
-		if err != nil {
-			log.Printf("terraform workspace new failed. workspace: %v . dir: %v", terraform.Workspace, terraform.WorkingDir)
-			return stdout.GetString(), "", fmt.Errorf("terraform select failed. %s", err)
-		}
-	}
+	var stdout, stderr bytes.Buffer
+	mwout := io.MultiWriter(os.Stdout, &stdout)
+	mwerr := io.MultiWriter(os.Stderr, &stderr)
+
+	cmd := exec.Command("terraform", args...)
+
+	cmd.Stdout = mwout
+	cmd.Stderr = mwerr
+
+	err := cmd.Run()
 
-	err = tf.Apply(context.Background())
 	if err != nil {
-		println("terraform plan failed.")
-		return stdout.GetString(), "", fmt.Errorf("terraform plan failed. %s", err)
+		fmt.Println("Error:", err)
 	}
 
-	return stdout.GetString(), "", nil
+	return stdout.String(), stderr.String(), cmd.ProcessState.ExitCode(), err
 }
 
 type StdWriter struct {
@@ -119,45 +139,27 @@ func (sw *StdWriter) GetString() string {
 	return s
 }
 
-func (terraform Terraform) Plan() (bool, string, string, error) {
-	execDir := "terraform"
-	tf, err := tfexec.NewTerraform(terraform.WorkingDir, execDir)
-
+func (tf Terraform) Plan(initParams []string, planParams []string) (bool, string, string, error) {
+	initParams = append(append(append(initParams, "-upgrade=true"), "-input=false"), "-no-color")
+	_, _, _, err := tf.runTerraformCommand("init", initParams...)
 	if err != nil {
-		println("Error while initializing terraform: " + err.Error())
-		os.Exit(1)
+		return false, "", "", err
 	}
-	stdout := &StdWriter{[]byte{}, true}
-	stderr := &StdWriter{[]byte{}, true}
-	tf.SetStdout(stdout)
-	tf.SetStderr(stderr)
+	workspace, _, _, err := tf.runTerraformCommand("workspace", "show")
 
-	err = tf.Init(context.Background(), tfexec.Upgrade(true))
 	if err != nil {
-		println("terraform init failed.")
-		return false, stdout.GetString(), stderr.GetString(), fmt.Errorf("terraform init failed. %s", err)
+		return false, "", "", err
 	}
-	currentWorkspace, err := tf.WorkspaceShow(context.Background())
-
-	if err != nil {
-		log.Printf("terraform workspace show failed. workspace: %v . dir: %v", terraform.Workspace, terraform.WorkingDir)
-		return false, stdout.GetString(), "", fmt.Errorf("terraform show failed. %s", err)
-	}
-
-	if currentWorkspace != terraform.Workspace {
-		err = tf.WorkspaceNew(context.Background(), terraform.Workspace)
-
+	if strings.TrimSpace(workspace) != tf.Workspace {
+		_, _, _, err = tf.runTerraformCommand("workspace", "new", tf.Workspace)
 		if err != nil {
-			log.Printf("terraform workspace new failed. workspace: %v . dir: %v", terraform.Workspace, terraform.WorkingDir)
-			return false, stdout.GetString(), "", fmt.Errorf("terraform select failed. %s", err)
+			return false, "", "", err
 		}
 	}
-
-	isNonEmptyPlan, err := tf.Plan(context.Background())
+	planParams = append(append(append(planParams, "-input=false"), "-no-color"), "-out=tfplan")
+	stdout, stderr, statusCode, err := tf.runTerraformCommand("plan", planParams...)
 	if err != nil {
-		println("terraform plan failed. dir: " + terraform.WorkingDir)
-		return isNonEmptyPlan, stdout.GetString(), stderr.GetString(), fmt.Errorf("terraform plan failed. %s", err)
+		return false, "", "", err
 	}
-
-	return isNonEmptyPlan, stdout.GetString(), stderr.GetString(), nil
+	return statusCode != 2, stdout, stderr, nil
 }
diff --git a/pkg/utils/locking.go b/pkg/utils/locking.go
index c17e4d2e..71f4b2d4 100644
--- a/pkg/utils/locking.go
+++ b/pkg/utils/locking.go
@@ -3,6 +3,7 @@ package utils
 import (
 	"digger/pkg/aws"
 	"digger/pkg/aws/envprovider"
+	"digger/pkg/azure"
 	"digger/pkg/gcp"
 	"digger/pkg/github"
 	"errors"
@@ -36,7 +37,7 @@ type Lock interface {
 type ProjectLock interface {
 	Lock(lockId string, prNumber int) (bool, error)
 	Unlock(lockId string, prNumber int) (bool, error)
-	ForceUnlock(lockId string, prNumber int)
+	ForceUnlock(lockId string, prNumber int) error
 }
 
 func (projectLock *ProjectLockImpl) Lock(lockId string, prNumber int) (bool, error) {
@@ -55,8 +56,6 @@ func (projectLock *ProjectLockImpl) Lock(lockId string, prNumber int) (bool, err
 			projectLock.PrManager.PublishComment(prNumber, comment)
 			return false, nil
 		}
-		comment := "Project " + projectLock.projectId() + " locked by this PR #" + transactionIdStr + " already."
-		projectLock.PrManager.PublishComment(prNumber, comment)
 		return true, nil
 	}
 
@@ -106,22 +105,30 @@ func (projectLock *ProjectLockImpl) Unlock(lockId string, prNumber int) (bool, e
 	return false, nil
 }
 
-func (projectLock *ProjectLockImpl) ForceUnlock(lockId string, prNumber int) {
+func (projectLock *ProjectLockImpl) ForceUnlock(lockId string, prNumber int) error {
 	fmt.Printf("ForceUnlock %s\n", lockId)
-	lock, _ := projectLock.InternalLock.GetLock(lockId)
+	lock, err := projectLock.InternalLock.GetLock(lockId)
+	if err != nil {
+		return err
+	}
 	if lock != nil {
-		lockReleased, _ := projectLock.InternalLock.Unlock(lockId)
+		lockReleased, err := projectLock.InternalLock.Unlock(lockId)
+		if err != nil {
+			return err
+		}
 
 		if lockReleased {
 			comment := "Project unlocked (" + projectLock.projectId() + ")."
 			projectLock.PrManager.PublishComment(prNumber, comment)
 			println("Project unlocked")
 		}
+		return nil
 	}
+	return nil
 }
 
 func (projectLock *ProjectLockImpl) projectId() string {
-	return projectLock.RepoOwner + "/" + projectLock.RepoName
+	return projectLock.RepoOwner + "/" + projectLock.RepoName + "#" + projectLock.ProjectName
 }
 
 func GetLock() (Lock, error) {
@@ -153,11 +160,14 @@ func GetLock() (Lock, error) {
 
 		bucketName := strings.ToLower(os.Getenv("GOOGLE_STORAGE_BUCKET"))
 		if bucketName == "" {
-			bucketName = "digger-lock"
+			return nil, errors.New("GOOGLE_STORAGE_BUCKET is not set")
 		}
 		bucket := client.Bucket(bucketName)
 		lock := gcp.GoogleStorageLock{Client: client, Bucket: bucket, Context: ctx}
 		return &lock, nil
+	} else if lockProvider == "azure" {
+		return azure.NewStorageAccountLock()
 	}
+
 	return nil, errors.New("failed to find lock provider")
 }
diff --git a/pkg/utils/mocks.go b/pkg/utils/mocks.go
index 68163988..acb40570 100644
--- a/pkg/utils/mocks.go
+++ b/pkg/utils/mocks.go
@@ -49,3 +49,19 @@ func (t MockPullRequestManager) GetChangedFiles(prNumber int) ([]string, error)
 func (t MockPullRequestManager) PublishComment(prNumber int, comment string) {
 
 }
+
+func (t MockPullRequestManager) SetStatus(prNumber int, status string, statusContext string) error {
+	return nil
+}
+
+func (t MockPullRequestManager) GetCombinedPullRequestStatus(prNumber int) (string, error) {
+	return "", nil
+}
+
+func (t MockPullRequestManager) MergePullRequest(prNumber int) error {
+	return nil
+}
+
+func (t MockPullRequestManager) IsMergeable(prNumber int) (bool, string, error) {
+	return true, "", nil
+}


Test patch:
diff --git a/cmd/digger/main_test.go b/cmd/digger/main_test.go
index bfdfabac..b119f509 100644
--- a/cmd/digger/main_test.go
+++ b/cmd/digger/main_test.go
@@ -873,8 +873,10 @@ func TestGitHubNewPullRequestContext(t *testing.T) {
 	lock := &utils.MockLock{}
 	prManager := &utils.MockPullRequestManager{ChangedFiles: []string{"dev/test.tf"}}
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, &diggerConfig, prManager)
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, context.RepositoryOwner, context.Repository, eventName, prNumber, &diggerConfig, prManager, lock, "")
+
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, context.RepositoryOwner, context.Repository, eventName, prNumber, &diggerConfig, prManager, lock, "")
+
 	assert.NoError(t, err)
 	if err != nil {
 		fmt.Println(err)
@@ -893,8 +895,10 @@ func TestGitHubNewCommentContext(t *testing.T) {
 	lock := &utils.MockLock{}
 	prManager := &utils.MockPullRequestManager{ChangedFiles: []string{"dev/test.tf"}}
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, &diggerConfig, prManager)
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, context.RepositoryOwner, context.Repository, eventName, prNumber, &diggerConfig, prManager, lock, "")
+
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, context.RepositoryOwner, context.Repository, eventName, prNumber, &diggerConfig, prManager, lock, "")
+
 	assert.NoError(t, err)
 	if err != nil {
 		fmt.Println(err)
@@ -914,17 +918,40 @@ func TestGitHubNewPullRequestInMultiEnvProjectContext(t *testing.T) {
 	assert.NoError(t, err)
 	ghEvent := context.Event
 	pullRequestNumber := 11
-	// digger config
-	dev := digger.Project{Name: "dev", Dir: "dev", WorkflowConfiguration: digger.WorkflowConfiguration{
-		OnPullRequestPushed: []string{"digger plan"},
-		OnPullRequestClosed: []string{"digger unlock"},
-		OnCommitToDefault:   []string{"digger apply"},
-	}}
-	prod := digger.Project{Name: "prod", Dir: "prod", WorkflowConfiguration: digger.WorkflowConfiguration{
-		OnPullRequestPushed: []string{"digger plan"},
-		OnPullRequestClosed: []string{"digger unlock"},
-		OnCommitToDefault:   []string{"digger apply"},
-	}}
+	dev := digger.Project{Name: "dev", Dir: "dev", Workflow: "dev"}
+	prod := digger.Project{Name: "prod", Dir: "prod", Workflow: "prod"}
+	workflows := map[string]digger.Workflow{
+		"dev": {
+			Plan: &digger.Stage{Steps: []digger.Step{
+				{Action: "init", ExtraArgs: []string{}},
+				{Action: "plan", ExtraArgs: []string{"-var-file=dev.tfvars"}},
+			}},
+			Apply: &digger.Stage{Steps: []digger.Step{
+				{Action: "init", ExtraArgs: []string{}},
+				{Action: "apply", ExtraArgs: []string{"-var-file=dev.tfvars"}},
+			}},
+			Configuration: &digger.WorkflowConfiguration{
+				OnPullRequestPushed: []string{"digger plan"},
+				OnPullRequestClosed: []string{"digger unlock"},
+				OnCommitToDefault:   []string{"digger apply"},
+			},
+		},
+		"prod": {
+			Plan: &digger.Stage{Steps: []digger.Step{
+				{Action: "init", ExtraArgs: []string{}},
+				{Action: "plan", ExtraArgs: []string{"-var-file=dev.tfvars"}},
+			}},
+			Apply: &digger.Stage{Steps: []digger.Step{
+				{Action: "init", ExtraArgs: []string{}},
+				{Action: "apply", ExtraArgs: []string{"-var-file=dev.tfvars"}},
+			}},
+			Configuration: &digger.WorkflowConfiguration{
+				OnPullRequestPushed: []string{"digger plan"},
+				OnPullRequestClosed: []string{"digger unlock"},
+				OnCommitToDefault:   []string{"digger apply"},
+			},
+		},
+	}
 	projects := []digger.Project{dev, prod}
 	diggerConfig := digger.DiggerConfig{Projects: projects}
 
@@ -933,7 +960,7 @@ func TestGitHubNewPullRequestInMultiEnvProjectContext(t *testing.T) {
 	lock := &utils.MockLock{}
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, &diggerConfig, prManager)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, workflows)
 	spew.Dump(lock.MapLock)
 	assert.Equal(t, pullRequestNumber, prNumber)
 	assert.Equal(t, 1, len(commandsToRunPerProject))
diff --git a/pkg/azure/storage_account_test.go b/pkg/azure/storage_account_test.go
new file mode 100644
index 00000000..184660a0
--- /dev/null
+++ b/pkg/azure/storage_account_test.go
@@ -0,0 +1,381 @@
+package azure
+
+import (
+	"fmt"
+	"net"
+	"os"
+	"testing"
+
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/suite"
+)
+
+// Default values to connect to Azurite
+const (
+	AZURITE_SA_NAME     = "devstoreaccount1"
+	AZURITE_CONN_STRING = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
+	AZURITE_SHARED_KEY  = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
+)
+
+var (
+	usingRealSA = false // Whether we are running tests against a real storage account or not.
+	envNames    = []string{
+		"DIGGER_AZURE_CONNECTION_STRING",
+		"DIGGER_AZURE_SHARED_KEY",
+		"DIGGER_AZURE_SA_NAME",
+		"DIGGER_AZURE_TENANT_ID",
+		"DIGGER_AZURE_CLIENT_ID",
+		"DIGGER_AZURE_CLIENT_SECRET",
+	}
+
+	// Holds our current environment
+	envs = map[string]string{}
+)
+
+type tt struct {
+	loadEnv func(*SALockTestSuite)
+	name    string
+}
+
+// We use a test table so we can run our tests,
+// using multiple authentication variations.
+var (
+	testCases = []tt{
+		{
+			name: "Connection string authentication mode",
+			loadEnv: func(*SALockTestSuite) {
+				loadConnStringEnv()
+			},
+		},
+		{
+			name: "Shared Key authentication mode",
+			loadEnv: func(*SALockTestSuite) {
+				loadSharedKeyEnv()
+			},
+		},
+		{
+			name: "Client secret authentication mode",
+			loadEnv: func(s *SALockTestSuite) {
+				// Skip this test case if we are not testing on a real storage account
+				if !usingRealSA {
+					s.T().Skip("Client secret method can only be tested when used against a real storage account.")
+					return
+				}
+				loadClientSecretEnv()
+			},
+		},
+	}
+)
+
+type SALockTestSuite struct {
+	suite.Suite
+}
+
+// Runs once before the test suite starts
+func (suite *SALockTestSuite) SetupSuite() {
+	// Prepare environment variables
+	prepareEnv(suite)
+
+	// Make sure Azurite is started before the tests
+	// if we are not using a real storage account
+	if usingRealSA {
+		return
+	}
+	conn, err := net.Dial("tcp", "127.0.0.1:10002")
+	if err != nil {
+		suite.T().Fatalf("Please make sure 'Azurite' table service is started before running Azure tests, or use a real storage account.")
+	}
+	conn.Close()
+
+	cleanEnv()
+}
+
+// Runs after every test
+func (suite *SALockTestSuite) TearDownTest() {
+	cleanEnv()
+}
+
+// Runs after every sub-test
+func (suite *SALockTestSuite) TearDownSubTest() {
+	cleanEnv()
+}
+
+func (suite *SALockTestSuite) TestNewStorageAccountLock() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+
+			suite.NotNil(sal)
+			suite.NoError(err)
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestNewStorageAccountLock_NoAuthMethods() {
+	sal, err := NewStorageAccountLock()
+
+	suite.Nil(sal)
+	suite.Error(err, "expected an error, since no authentication mecanism was provided")
+}
+
+func (suite *SALockTestSuite) TestNewStorageAccountLock_WithSharedKey_MissingAccountName() {
+	loadSharedKeyEnv()
+	os.Setenv("DIGGER_AZURE_SA_NAME", "")
+
+	sal, err := NewStorageAccountLock()
+	suite.Nil(sal)
+	suite.Error(err, "should have got an error")
+}
+
+func (suite *SALockTestSuite) TestNewStorageAccountLock_WithClientSecret_MissingEnv() {
+	loadClientSecretEnv()
+	os.Setenv("DIGGER_AZURE_CLIENT_ID", "")
+
+	sal, err := NewStorageAccountLock()
+	suite.Nil(sal)
+	suite.Error(err, "should have got an error")
+}
+
+func (suite *SALockTestSuite) TestLock_WhenNotLockedYet() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, _ := NewStorageAccountLock()
+			ok, err := sal.Lock(18, generateResourceName())
+
+			suite.True(ok, "lock acquisition should be true")
+			suite.NoError(err, "error while acquiring lock")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestLock_WhenAlreadyLocked() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+			suite.NotNil(sal)
+			suite.NoError(err)
+			resourceName := generateResourceName()
+
+			// Locking the first time
+			ok, err := sal.Lock(18, resourceName)
+			suite.True(ok, "lock acquisition should be true")
+			suite.NoError(err, "should not have got an error")
+
+			// Lock the second time on the same resource name
+			ok, err = sal.Lock(18, resourceName)
+			suite.False(ok, "lock acquisition should be false")
+			suite.NoError(err, "should not have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestUnlock() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, _ := NewStorageAccountLock()
+			resourceName := generateResourceName()
+
+			// Locking
+			ok, err := sal.Lock(18, resourceName)
+			suite.True(ok, "lock acquisition should be true")
+			suite.NoError(err, "should not have got an error")
+
+			// Unlocking
+			ok, err = sal.Unlock(resourceName)
+			suite.True(ok)
+			suite.NoError(err, "should not have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestUnlock_WhenLockDoesNotExist() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+			suite.NotNil(sal)
+			suite.Require().NoError(err)
+
+			// Unlocking
+			resourceName := generateResourceName()
+			ok, err := sal.Unlock(resourceName)
+			suite.False(ok)
+			suite.Error(err, "should have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestUnlock_Twice_WhenLockExist() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, _ := NewStorageAccountLock()
+			resourceName := generateResourceName()
+
+			// Locking
+			ok, err := sal.Lock(18, resourceName)
+			suite.True(ok, "lock acquisition should be true")
+			suite.NoError(err, "should not have got an error")
+
+			// Unlocking the first time
+			ok, err = sal.Unlock(resourceName)
+			suite.True(ok)
+			suite.NoError(err, "should not have got an error")
+
+			// Unlocking the second time
+			ok, err = sal.Unlock(resourceName)
+			suite.False(ok)
+			suite.Error(err, "should have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestGetLock() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+			suite.NotNil(sal)
+			suite.Require().NoError(err)
+
+			// Locking
+			resourceName := generateResourceName()
+			ok, err := sal.Lock(21, resourceName)
+			suite.Require().True(ok, "lock acquisition should be true")
+			suite.Require().NoError(err, "should not have got an error")
+
+			// Get the lock
+			transactionId, err := sal.GetLock(resourceName)
+			suite.Equal(21, *transactionId, "transaction id mismatch")
+			suite.NoError(err, "should not have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestGetLock_WhenLockDoesNotExist() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+			suite.Require().NotNil(sal)
+			suite.Require().NoError(err)
+
+			// Get a lock that doesn't exist
+			resourceName := generateResourceName()
+			transactionId, err := sal.GetLock(resourceName)
+			suite.Nil(transactionId, "transaction id should be nil")
+			suite.NoError(err, "should not have got an error")
+		})
+	}
+}
+
+func (suite *SALockTestSuite) TestGetLock_WithForbiddenCharacters() {
+	for _, tc := range testCases {
+		suite.Run(tc.name, func() {
+			tc.loadEnv(suite)
+			sal, err := NewStorageAccountLock()
+			suite.NotNil(sal)
+			suite.Require().NoError(err)
+
+			// Locking
+			resourceName := fmt.Sprintf("digger/diggerhq/%s#project/", generateResourceName())
+			ok, err := sal.Lock(21, resourceName)
+			suite.Require().True(ok, "lock acquisition should be true")
+			suite.Require().NoError(err, "should not have got an error")
+
+			// Get the lock
+			transactionId, err := sal.GetLock(resourceName)
+			suite.Equal(21, *transactionId, "transaction id mismatch")
+			suite.NoError(err, "should not have got an error")
+		})
+	}
+}
+
+// Entrypoint of the test suite
+func TestStorageAccountLockTestSuite(t *testing.T) {
+	// To test Azure Storage account we can either:
+	// 1. use a real storage account if we have one
+	// 2. or use a local storage account emulator (Azurite)
+
+	useRealSA := os.Getenv("DIGGER_TEST_USE_REAL_SA")
+
+	// Override the service url format
+	// if we are not using a real storage account
+	// We'll be relying on Azurite emulator which has
+	// a different url format
+	if useRealSA == "" || useRealSA == "0" {
+		SERVICE_URL_FORMAT = "http://127.0.0.1:10002/%s"
+		usingRealSA = false
+		suite.Run(t, new(SALockTestSuite))
+		return
+	}
+
+	usingRealSA = true
+	suite.Run(t, new(SALockTestSuite))
+}
+
+// Generate unique string to be used as resource name
+// so we don't have to delete the entity
+func generateResourceName() string {
+	return uuid.New().String()
+}
+
+// Initialize and save environment variables into a map.
+// This is useful so we can alter  environment variables
+// without losing their initial values.
+
+// This comes is handy when we want to test cases
+// where an environment variable is not defined.
+func prepareEnv(s *SALockTestSuite) {
+	// When using Azurite, environment variables are known
+	if !usingRealSA {
+		envs["DIGGER_AZURE_SHARED_KEY"] = AZURITE_SHARED_KEY
+		envs["DIGGER_AZURE_SA_NAME"] = AZURITE_SA_NAME
+		envs["DIGGER_AZURE_CONNECTION_STRING"] = AZURITE_CONN_STRING
+		return
+	}
+
+	// When using real storage account, environment
+	// variables are not known, and must be injected by the user
+	// before starting our tests.
+	for _, env := range envNames {
+		envValue, exists := os.LookupEnv(env)
+		if !exists {
+			s.T().Fatalf("Since 'DIGGER_TEST_USE_REAL_SA' has been set, '%s' environment variable must also be set before starting the tests.", env)
+		}
+
+		envs[env] = envValue
+	}
+}
+
+func loadSharedKeyEnv() {
+	os.Setenv("DIGGER_AZURE_AUTH_METHOD", "SHARED_KEY")
+
+	os.Setenv("DIGGER_AZURE_SHARED_KEY", envs["DIGGER_AZURE_SHARED_KEY"])
+	os.Setenv("DIGGER_AZURE_SA_NAME", envs["DIGGER_AZURE_SA_NAME"])
+}
+
+func loadConnStringEnv() {
+	os.Setenv("DIGGER_AZURE_AUTH_METHOD", "CONNECTION_STRING")
+	os.Setenv("DIGGER_AZURE_CONNECTION_STRING", envs["DIGGER_AZURE_CONNECTION_STRING"])
+}
+
+func loadClientSecretEnv() {
+	os.Setenv("DIGGER_AZURE_AUTH_METHOD", "CLIENT_SECRET")
+
+	os.Setenv("DIGGER_AZURE_TENANT_ID", envs["DIGGER_AZURE_TENANT_ID"])
+	os.Setenv("DIGGER_AZURE_CLIENT_ID", envs["DIGGER_AZURE_CLIENT_ID"])
+	os.Setenv("DIGGER_AZURE_CLIENT_SECRET", envs["DIGGER_AZURE_CLIENT_SECRET"])
+	os.Setenv("DIGGER_AZURE_SA_NAME", envs["DIGGER_AZURE_SA_NAME"])
+}
+
+// Clean environment variables
+func cleanEnv() {
+	for _, env := range envNames {
+		os.Setenv(env, "")
+	}
+}
diff --git a/pkg/digger/digger_config_test.go b/pkg/digger/digger_config_test.go
index b5e36ccf..771745b9 100644
--- a/pkg/digger/digger_config_test.go
+++ b/pkg/digger/digger_config_test.go
@@ -88,20 +88,21 @@ func TestDefaultValuesForWorkflowConfiguration(t *testing.T) {
 	defer teardown()
 
 	diggerCfg := `
-projects:
-- name: dev
-  branch: /main/
-  dir: .
-  workspace: default
+workflows:
+  default:
+    plan:
+      steps:
+      - init
+      - plan:
+          extra_args: ["-var-file=terraform.tfvars"]
 `
 	deleteFile := createFile(path.Join(tempDir, "digger.yaml"), diggerCfg)
 	defer deleteFile()
 
 	dg, err := NewDiggerConfig(tempDir)
 	assert.NoError(t, err, "expected error to be nil")
-	assert.Equal(t, dg.Projects[0].WorkflowConfiguration.OnPullRequestPushed[0], "digger plan")
-	assert.Equal(t, dg.Projects[0].WorkflowConfiguration.OnPullRequestClosed[0], "digger unlock")
-	assert.Equal(t, dg.Projects[0].WorkflowConfiguration.OnCommitToDefault[0], "digger apply")
+	assert.Equal(t, Step{"init", nil}, dg.Workflows["default"].Plan.Steps[0], "expected step name to be 'init'")
+	assert.Equal(t, Step{"plan", []string{"-var-file=terraform.tfvars"}}, dg.Workflows["default"].Plan.Steps[1], "expected step name to be 'init'")
 }
 
 func createTempDir() string {
diff --git a/pkg/integration/integration_test.go b/pkg/integration/integration_test.go
index ad1ba4c0..1bea3f69 100644
--- a/pkg/integration/integration_test.go
+++ b/pkg/integration/integration_test.go
@@ -379,9 +379,9 @@ func TestHappyPath(t *testing.T) {
 	//  new pr should lock the project
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
 	assert.NoError(t, err)
 
 	projectLock := &utils.ProjectLockImpl{
@@ -406,9 +406,9 @@ func TestHappyPath(t *testing.T) {
 	// 'digger plan' comment should trigger terraform execution
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
 	assert.NoError(t, err)
 
 	println("--- digger apply comment ---")
@@ -420,9 +420,9 @@ func TestHappyPath(t *testing.T) {
 	// 'digger apply' comment should trigger terraform execution and unlock the project
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
 	assert.NoError(t, err)
 
 	projectLock = &utils.ProjectLockImpl{
@@ -444,9 +444,9 @@ func TestHappyPath(t *testing.T) {
 
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, lock, dir)
 	assert.NoError(t, err)
 
 	projectLock = &utils.ProjectLockImpl{
@@ -522,9 +522,9 @@ func TestMultiEnvHappyPath(t *testing.T) {
 	// no files changed, no locks
 	impactedProjects, prNumber, err := digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err := digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
 	assert.NoError(t, err)
 
 	projectLock := &utils.ProjectLockImpl{
@@ -548,9 +548,9 @@ func TestMultiEnvHappyPath(t *testing.T) {
 	// 'digger plan' comment should trigger terraform execution
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
 	assert.NoError(t, err)
 
 	println("--- digger apply comment ---")
@@ -562,9 +562,9 @@ func TestMultiEnvHappyPath(t *testing.T) {
 	// 'digger apply' comment should trigger terraform execution and unlock the project
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
 	assert.NoError(t, err)
 
 	projectLock = &utils.ProjectLockImpl{
@@ -586,9 +586,9 @@ func TestMultiEnvHappyPath(t *testing.T) {
 
 	impactedProjects, prNumber, err = digger.ProcessGitHubEvent(ghEvent, diggerConfig, githubPrService)
 	assert.NoError(t, err)
-	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects)
+	commandsToRunPerProject, err = digger.ConvertGithubEventToCommands(ghEvent, impactedProjects, map[string]digger.Workflow{})
 	assert.NoError(t, err)
-	err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
+	_, err = digger.RunCommandsPerProject(commandsToRunPerProject, repoOwner, repositoryName, eventName, prNumber, diggerConfig, githubPrService, &dynamoDbLock, dir)
 	assert.NoError(t, err)
 
 	projectLock = &utils.ProjectLockImpl{
diff --git a/pkg/terraform/tf_test.go b/pkg/terraform/tf_test.go
index 0c1c0bfc..a2dc6a94 100644
--- a/pkg/terraform/tf_test.go
+++ b/pkg/terraform/tf_test.go
@@ -19,7 +19,7 @@ func TestExecuteTerraformPlan(t *testing.T) {
 	CreateValidTerraformTestFile(dir)
 
 	tf := Terraform{WorkingDir: dir, Workspace: "dev"}
-	_, _, _, err := tf.Plan()
+	_, _, _, err := tf.Plan([]string{}, []string{})
 	assert.NoError(t, err)
 }
 
@@ -35,7 +35,7 @@ func TestExecuteTerraformApply(t *testing.T) {
 	CreateValidTerraformTestFile(dir)
 
 	tf := Terraform{WorkingDir: dir, Workspace: "dev"}
-	_, _, err := tf.Apply()
+	_, _, _, err := tf.Plan([]string{}, []string{})
 	assert.NoError(t, err)
 }
 
@@ -51,6 +51,6 @@ func TestExecuteTerraformApplyDefaultWorkspace(t *testing.T) {
 	CreateValidTerraformTestFile(dir)
 
 	tf := Terraform{WorkingDir: dir, Workspace: "default"}
-	_, _, err := tf.Apply()
+	_, _, err := tf.Apply([]string{}, []string{})
 	assert.NoError(t, err)
 }