Git push policy

Purpose

Git push policies are triggered on a per-stack basis to determine the action that should be taken for each individual Stack or Module in response to a Git push notification. There are three possible outcomes:

  • track: set the new head commit on the stack / module and create a tracked Run, ie. one that that can be applied;

  • propose: create a proposed Run against a proposed version of infrastructure;

  • ignore: do not schedule a new Run;

Using this policy it is possible to create a very sophisticated, custom-made setup. We can think of two main - and not mutually exclusive - use cases. The first one would be to ignore changes to certain paths - something you'd find useful both with classic monorepos and repositories containing multiple Terraform projects under different paths. The second one would be to only attempt to apply a subset of changes - for example, only commits tagged in a certain way.

Git push policy and tracked branch

Each stack and module points at a particular Git branch called a tracked branch. By default, any push to the tracked branch triggers a tracked Run that can be applied. This logic can be changed entirely by a Git push policy, but the tracked branch is always reported as part of the Stack input to the policy evaluator and can be used as a point of reference.

Corner case: track, don't trigger

The track decision sets the new head commit on the affected stack or module. This head commit is what is going to be used when a tracked run is manually triggered, or a task is started on the stack. Usually what you want in this case is to have a new tracked Run, so this is what we do by default.

Sometimes, however, you may want to trigger those tracked runs in a specific order or under specific circumstances - either manually or using a trigger policy. So what you want is an option to set the head commit, but not trigger a run. This is what the boolean notrigger rule can do for you. notrigger will only work in conjunction with track decision and will prevent the tracked run from being created.

Data input

As input, Git push policy receives the following document:

{
"push": {
"affected_files": ["string"],
"author": "string",
"branch": "string",
"created_at": "number (timestamp in nanoseconds)",
"message": "string",
"tag": "string"
},
"stack": {
"administrative": "boolean",
"branch": "string",
"labels": ["string - list of arbitrary, user-defined selectors"],
"locked_by": "optional string - if the stack is locked, this is the name of the user who did it",
"name": "string",
"namespace": "string - repository namespace, only relevant to GitLab repositories",
"project_root": "optional string - project root as set on the Stack, if any",
"repository": "string",
"state": "string",
"terraform_version": "string or null"
},
"stacks": [{
"administrative": "boolean - is the stack administrative",
"autodeploy": "boolean - is the stack currently set to autodeploy",
"branch": "string - tracked branch of the stack",
"id": "string - unique stack identifier",
"labels": ["string - list of arbitrary, user-defined selectors"],
"locked_by": "optional string - if the stack is locked, this is the name of the user who did it",
"name": "string - name of the stack",
"namespace": "string - repository namespace, only relevant to GitLab repositories",
"project_root": "optional string - project root as set on the Stack, if any",
"repository": "string - name of the source GitHub repository",
"state": "string - current state of the stack",
"terraform_version": "string or null - last Terraform version used to apply changes"
}]
}

Note the presence of two similar keys: stack and stacks. The former is the Stack that the newly finished Run belongs to. The other is a list of all Stacks referencing the same git repository. The schema for both is the same.

Based on this input, the policy may define boolean track, propose and ignore rules. The positive outcome of at least one ignore rule causes the push to be ignored, no matter the outcome of other rules. The positive outcome of at least one track rule triggers a tracked run. The positive outcome of at least one propose rule triggers a proposed run.

If no rules are matched, the default is to ignore the push. Therefore it is important to always supply an exhaustive set of policies - that is, making sure that they define what to track and what to propose in addition to defining what they ignore.

It is also possible to define an auxiliary rule called ignore_track, which overrides a positive outcome of the track rule but does not affect other rules, most notably the propose one. This can be used to turn some of the pushes that would otherwise be applied into test runs.

Use cases

Ignoring certain paths

Ignoring changes to certain paths is something you'd find useful both with classic monorepos and repositories containing multiple Terraform projects under different paths. When evaluating a push, we determine the list of affected files by looking at all the files touched by any of the commits in a given push.

This list may include false positives - eg. in a situation where you delete a given file in one commit, then bring it back in another commit, and then push multiple commits at once. This is a safer default than trying to figure out the exact scope of each push.

Let's imagine a situation where you only want to look at changes to Terraform definitions - in HCL or JSON - inside one the production/ or modules/ directory, and have track and propose use their default settings:

package spacelift
track { input.push.branch == input.stack.branch }
propose { input.push.branch != "" }
ignore { not affected }
affected {
some i, j, k
tracked_directories := {"modules/", "production/"}
tracked_extensions := {".tf", ".tf.json"}
path := input.push.affected_files[i]
startswith(path, tracked_directories[j])
endswith(path, tracked_extensions[k])
}

As an aside, note that in order to keep the example readable we had to define ignore in a negative way as per the Anna Karenina principle. A minimal example of this policy is available here.

Status checks and ignored pushes

By default when the push policy instructs Spacelift to ignore a certain change, no commit status check is sent back to the VCS. This behavior is explicitly designed to prevent noise in monorepo scenarios where a large number of stacks are linked to the same Git repo.

However, in certain cases one may still be interested in learning that the push was ignored, or just getting a commit status check for a given stack when it's set as required as part of GitHub branch protection set of rules, or simply your internal organization rules.

In that case, you may find the notify rule useful. The purpose of this rule is to override default notification settings. So if you want to notify your VCS vendor even when a commit is ignored, you can define it like this:

package spacelift
# other rules (including ignore), see above
notify { ignore }

The notify rule (false by default) only applies to ignored pushes, so you can't set it to false to silence commit status checks for proposed runs.

Applying from a tag

Another possible use case of a Git push policy would be to apply from a newly created tag rather than from a branch. This in turn can be useful in multiple scenarios - for example, a staging/QA environment could be deployed every time a certain tag type is applied to a tested branch, thereby providing inline feedback on a GitHub Pull Request from the actual deployment rather than a plan/test. One could also constrain production to only apply from tags, unless a Run is explicitly triggered by the user.

Here's an example of one such policy:

package spacelift
track { re_match(`^\d+\.\d+\.\d+$`, input.push.tag) }
propose { input.push.branch != input.stack.branch }

Default Git push policy

If no Git push policies are attached to a stack or a module, the default behavior is equivalent to this policy:

package spacelift
track { input.push.branch == input.stack.branch }
propose { input.push.branch != "" }
ignore { input.push.branch == "" }