Custom Post-Receive Hooks
Asynchronous Post Hooks
By default, custom post-hooks execute synchronously. This means that repository pushes are delayed until the post-hook has completed execution.
If your custom post-hook does not require the push to have completed before execution, you should prefer executing asynchronously. This prevents a noticeable delay in pushes to end-users. Executing asynchronously also means that post-hooks can execute in parallel.
Synchronous post-hooks are not triggered by UI interactions, such as editing a file in the built-in file editor within Bitbucket.
Asynchronous execution is an opt-in because it changes some hook behaviours, such as the ability to write messages to the Git client.
To opt-in to asynchronous execution, check the Execute asynchronously checkbox:
Once asynchronous execution has been enabled, you are able to select custom triggers that only support asynchronous execution, such as file-edit.
Asynchronous post-hooks are not able to write messages to the Git client on push. If your use case requires writing a message to the Git client, you must use a synchronous post-hook.
Samples
The following examples will walk you through writing a custom post-receive hook.
The same variables as in pre-receive hooks are available in the binding.
Push Traceability
This relates to BSERV-2642. In this example we use a post-receive hook to store the user ID of the person that pushed commits in git notes. Note that the person pushing may not necessarily be the person that authored the commits.
If you want to validate commit authors and prevent pushing of commits that don’t follow your policy, see the pre-recieve hook, Enforce Trusted Commit Authors. This post-receive hook just records the credentials of the pushing user, for later verification.
You can view the notes by fetching them and using an argument to git log
:
git fetch origin refs/notes/push-traceability:refs/notes/push-traceability git log --show-notes=push-traceability
> commit 4c0b324d36b961d384afcba3a2f831d23486e194 > Author: Administrator <admin@example.com> > Date: Fri Sep 11 13:48:32 2015 +0100 > > Added testfile > > Notes (push-traceability): > { > "date": "2015-09-11T02:03:17+0100", > "userId": 1267, > "userName": "jechlin" > }
Read more about git notes in the documentation and a helpful blog post.
The script is a Custom Post-Hook post-receive hook:
import com.atlassian.plugin.PluginAccessor import com.atlassian.sal.api.component.ComponentLocator import com.atlassian.bitbucket.repository.RefChange import com.atlassian.bitbucket.repository.Repository import com.atlassian.bitbucket.scm.git.command.GitCommandBuilderFactory import com.atlassian.bitbucket.auth.AuthenticationContext import com.onresolve.scriptrunner.canned.bitbucket.bulkedit.StringCommandOutputHandler import com.onresolve.scriptrunner.runner.ScriptRunnerImpl import groovy.json.JsonBuilder import org.apache.commons.lang.SystemUtils def repository = repository as Repository def refChanges = refChanges as Collection<RefChange> final String NAMESPACE = "push-traceability" def AuthenticationContext = ComponentLocator.getComponent(AuthenticationContext) def pluginAccessor = ComponentLocator.getComponent(PluginAccessor) def gitCommandBuilderFactory = ScriptRunnerImpl.getOsgiService(GitCommandBuilderFactory) def currentUser = AuthenticationContext.getCurrentUser() def pushDetails = [ date : new Date().format("yyyy-MM-dd'T'hh:mm:ssZ"), userId : currentUser.id, userName: currentUser.name, ] def pushDetailsJson = new JsonBuilder(pushDetails).toPrettyString() if (SystemUtils.IS_OS_WINDOWS) { pushDetailsJson = pushDetailsJson.replaceAll("\"", /\\"/) } refChanges.getCommits(repository).each { commit -> gitCommandBuilderFactory.builder(repository) .command("notes") .argument("--ref=$NAMESPACE") .argument("add") .argument("-f") .argument("-m") .argument(pushDetailsJson) .argument(commit.id) .build(new StringCommandOutputHandler()).call() }
In order to prevent anyone updating the notes in this namespace, you can add a Protect Git Refs pre-recieve hook, where the condition is:
refChanges.any {it.ref.id == "refs/notes/push-traceability"}
It’s possible to add the push details to the commits page in the Bitbucket UI. We’ll add this if there is interest.
Update Static Site
This is an example of deploying static content such as a web site to a web server. Either the head
of master
could be deployed, or you can make use of a floating release label, called for instance PRODUCTION
. When we detect the label has moved we deploy the content to the web server.
Typically you would copy using scp
or rsync
, which requires password-less SSH access to the remote web server. In this example we’ll just assume that the web server is serving content from a local directory for simplicity.
You could easily extend this to support additional labels, such as STAGING
. The staging tag would be at or head of the production tag, and would deploy to a staging server for QA or user acceptance testing.
If you are deploying to a remote web server you can avoid all of this, and just replace with a command such as:
git archive --format zip PRODUCTION | \ ssh admin@web.acme.com -c " | tar -x -C /path/to/doc/root"
This script looks more complex than it really is. All it does is:
If the target directory doesn’t exist, create it
If the target directory is empty, clone the repository
Update to the
PRODUCTION
tag
import com.atlassian.bitbucket.hook.HookResponse import com.atlassian.bitbucket.io.SingleLineOutputHandler import com.atlassian.bitbucket.repository.RefChange import com.atlassian.bitbucket.repository.RefChangeType import com.atlassian.bitbucket.repository.Repository import com.onresolve.scriptrunner.canned.bitbucket.util.BitbucketBaseScript import com.onresolve.scriptrunner.canned.bitbucket.util.BitbucketCannedScriptUtils import groovy.transform.BaseScript @BaseScript BitbucketBaseScript baseScript Repository repository = repository Collection<RefChange> refChanges = refChanges HookResponse hookResponse = hookResponse final def productionTag = "PRODUCTION" final def webDirLocation = System.getProperty("java.io.tmpdir") + "/web/" if (!refChanges.any { it.ref.id.startsWith("refs/tags/$productionTag") && it.type in [RefChangeType.ADD, RefChangeType.UPDATE] }) { log.debug("No change to tag, exiting") return } def target = new File(webDirLocation) def handler = new SingleLineOutputHandler() if (!target.exists()) { target.mkdirs() } if (!target.list()) { gitCommandBuilderFactory.builder(repository) .clone() .normal() .origin(repository) .directory(webDirLocation) .build() .call() } else { gitCommandBuilderFactory.builder(repository) .push() .refspec("refs/heads/*") .refspec("refs/tags/*") .force(true) .repository(webDirLocation) .build(handler) .call() } gitCommandBuilderFactory.builder() .workingDirectory(webDirLocation) .command("checkout") .argument(productionTag) .build(handler) .call() def msg = new StringBuilder("I have updated the website at $webDirLocation for you") hookResponse.out().print(BitbucketCannedScriptUtils.wrapHookResponse(msg))
Line 10: Extend a base script, which gives us access to gitCommandBuilderFactory
Line 17: Path to web site directory, that should be updated. I am using tmp just for test purposes. This should be an empty directory, or a path that will be created.
Line 34: Target directory doesn’t exist, so create a clone of this repository there
Line 43: Target directory exists, so force push all refs to it
Line 53: Checkout web dir clone to tag
Line 60: Inform the user so they can do a quick sanity check
Using the New Hooks API
There are a number of reasons why you may want to write your custom hook using the new Hooks API approach, which are highlighted below.
Hook Triggers
UI changes
Changes aren’t always pushed in Bitbucket Server, you can for example create branches and tags through the UI. The old approach was to have a hook to handle the pushes and an event handler to handle the UI action. This resulted in duplication of business logic.
For example if you wanted a hook that checks branch names you could apply the following triggers to it:
branch-create
repo-push
The full list of triggers are explained in more detail here.
Checking Triggers
You can respond to different triggers in different ways by checking the trigger in the hook request.
The example below checks for tag create or branch create triggers:
import com.atlassian.bitbucket.hook.repository.StandardRepositoryHookTrigger if (hookRequest.trigger == StandardRepositoryHookTrigger.BRANCH_CREATE) { // handle branch create } else if (hookRequest.trigger == StandardRepositoryHookTrigger.TAG_CREATE) { // handle tag create }
Commit Details
Previously to get a list of commits that we’re being pushed you could use refChanges.getCommits()
or use the CommitService
directly.
The new Hooks API helps you to get commits by providing them to you one by one. This forces you to write your hook in a more performant way when you have pushes containing a large number of commits.
This is best illustrated in the following example which shows a hook that logs the ids of commits that were added or removed. The part we’re interested in is the commitCallback
section which provides us with the commits and gets the ids.
import com.atlassian.bitbucket.hook.repository.CommitAddedDetails import com.atlassian.bitbucket.hook.repository.CommitRemovedDetails import com.atlassian.bitbucket.hook.repository.PreRepositoryHookCommitCallback import com.atlassian.bitbucket.hook.repository.RepositoryHookResult import javax.annotation.Nonnull import static com.atlassian.bitbucket.hook.repository.RepositoryHookCommitFilter.ADDED_TO_ANY_REF import static com.atlassian.bitbucket.hook.repository.RepositoryHookCommitFilter.REMOVED_FROM_ANY_REF commitCallback = new PreRepositoryHookCommitCallback() { @Override boolean onCommitAdded(@Nonnull CommitAddedDetails commitDetails) { def commit = commitDetails.commit log.info("Commit added - $commit.displayId") return true } @Override boolean onCommitRemoved(@Nonnull CommitRemovedDetails commitDetails) { def commit = commitDetails.commit log.info("Commit removed - $commit.displayId") return true } @Override RepositoryHookResult getResult() { RepositoryHookResult.accepted() } } commitFilters = [ADDED_TO_ANY_REF, REMOVED_FROM_ANY_REF]
Line 12: Create a commit callback which will give us the commits that have been pushed.
Line 16: Get a single commit
from the CommitDetails
when a commit is added.
Line 17: Log the commit id.
Line 18: Return true to move onto the next commit.
Line 30: Always accept the push in a post-hook.
Line 34: We want to check commit messages whenever a commit has been added or removed so we add ADDED_TO_ANY_REF
and REMOVED_FROM_ANY_REF
to the commitFilters
binding.
The commitCallback
is simply created in step 1, it is only run after step 6.
Another confusing concept is the commitFilter
binding. This just indicates what type of commits do I want to check. The full list of filters can be found here.
It’s recommended you use this pattern whenever you need to check commits in a hook.
Further information on getting commit details are explained here.