Docs

Pre-Release Hook Script Reference

The exit-code contract, release-context JSON schema, and working Python and Shell samples for hand-written Facets pre-release approval hooks.

The Builder in the project's Release Approval settings covers most gating rules. Write the hook yourself when the rule depends on something the Builder's three criteria lists do not express, for example the change type of a resource, or a naming pattern across resource paths. This page documents the contract your script must satisfy.

You author a hand-written hook on the Script tab of the Release Approval drawer, in either Python or Shell.


Where the script lives

Facets stores the hook as a single script committed to the project's blueprint Git repository:

LanguagePath
Pythonpre_release_hooks/pre_release_hook.py
Shellpre_release_hooks/pre_release_hook.sh

Both paths are relative to the blueprint repository root. A project has one pre-release hook, and the pre-release hook is the only hook type Facets runs today. Saving from the settings page commits the file; removing the configuration deletes it.


The exit-code contract

The release wrapper runs your script and reads its process exit code. That code is the entire gate.

Exit codeWhat the release does
2The release stops at the gate and moves to Pending Approval
0The release proceeds
Any other non-zero codeThe release fails
🚧

Exit 0 or 2 explicitly on every path through your script, including error paths.

Any other non-zero exit code fails the release outright rather than gating it, and a hook that crashes or raises an unhandled error produces one. A crash therefore stops the release, but it stops it as a failure, not as a request for approval.

Decide what a missing context file, unparseable JSON, or the wrong argument count should mean for your gate and exit 2 (hold the release) or 0 (let it proceed) deliberately, rather than leaving the release to fail.


The release context JSON

Facets serializes the release context to a JSON file and passes that file's path as the script's single argument. Your script opens the path itself; the JSON never arrives on standard input.

{
  "environmentName": "<environmentName>",
  "triggeredBy": "<triggeredBy>",
  "releaseStream": "<releaseStream>",
  "resources": [
    {
      "resourceType": "<resourceType>",
      "resourceName": "<resourceName>",
      "resourcePath": "<resourcePath>",
      "changeType": "<changeType>"
    }
  ]
}

The resources array holds one entry per resource the release touches. changeType is one of CREATE, DELETE, UPDATE_IN_PLACE, REPLACE, RECREATE, or NO_OPERATION.

triggeredBy is the username of the user who triggered the release. It is never blank: when a release is created without a triggering user already set, Facets fills it in from the authenticated principal, and falls back to Deployer when there is no authenticated principal at all. Do not test it against an empty string.


Python example

This hook requires approval for every release on the PROD release stream.

import sys
import json

def main():
    if len(sys.argv) != 2:
        print("Usage: python pre_release_hook.py <path_to_json_file>")
        sys.exit(1)

    json_file_path = sys.argv[1]

    try:
        with open(json_file_path, 'r') as file:
            data = json.load(file)

        environment_name = data.get("environmentName")
        release_stream = data.get("releaseStream")
        resources = data.get("resources", [])

        print(f"Environment Name: {environment_name}")
        print(f"Release Stream: {release_stream}")
        print("Resources:")
        for resource in resources:
            print(f"  - Resource Type: {resource.get('resourceType')}")
            print(f"    Resource Name: {resource.get('resourceName')}")
            print(f"    Resource Path: {resource.get('resourcePath')}")
            print(f"    Change Type: {resource.get('changeType')}")

        if release_stream == "PROD":
            print(f"Exiting with exit code '2' since Release Stream is {release_stream}")
            sys.exit(2)

    except FileNotFoundError:
        print(f"Error: File not found - {json_file_path}")
        sys.exit(1)
    except json.JSONDecodeError:
        print(f"Error: Failed to decode JSON from file - {json_file_path}")
        sys.exit(1)

if __name__ == "__main__":
    main()

Shell example

The same rule in Shell. A Shell hook depends on jq to read the context file. Without jq on the release runtime the script cannot parse its input at all, so it never reaches its gating decision. Handle that case explicitly rather than letting the script fall through to an undefined exit code.

#!/usr/bin/env bash
#
# Requires jq.

if [ "$#" -ne 1 ]; then
  echo "Usage: pre_release_hook.sh <path_to_json_file>"
  exit 1
fi

json_file="$1"

if [ ! -f "$json_file" ]; then
  echo "Error: File not found - $json_file"
  exit 1
fi

if ! jq empty "$json_file" >/dev/null 2>&1; then
  echo "Error: Failed to decode JSON from file - $json_file"
  exit 1
fi

release_stream=$(jq -r '.releaseStream // empty' "$json_file")
echo "Release Stream: $release_stream"

if [ "$release_stream" = "PROD" ]; then
  echo "Exiting with exit code '2' since Release Stream is $release_stream"
  exit 2
fi

Builder-generated scripts and the config markers

A script the Builder generates carries the selections you made, written as a JSON block between two sentinel comment markers:

# === FACETS_RELEASE_APPROVAL_CONFIG_START ===
# === FACETS_RELEASE_APPROVAL_CONFIG_END ===

The settings page reads that block back to repopulate the Builder form, which is how a saved rule round-trips into the same criteria lists you picked it from.

A script with no recognizable config block is treated as hand-authored. The settings page presents it as a Custom script, and the drawer opens straight into the Script tab rather than the Builder. Editing the markers by hand, or removing them from a generated script, changes that presentation: a generated hook whose markers you delete stops round-tripping and starts showing as a Custom script.

📘

Leave the config block alone in a Builder-generated hook. Change the rule from the Builder instead, and Facets rewrites the script and its config block together.


From the CLI

The raptor CLI installs, reads, and removes the hook without going through the settings page. --type selects the hook kind and defaults to pre-release, the only type today.

raptor apply hook --type pre-release -p PROJECT --script-file ./pre_release_hook.py
raptor get hook --type pre-release -p PROJECT
raptor delete hook --type pre-release -p PROJECT --yes

apply commits the script to the blueprint repository, and replaces an existing hook of the same language. Installing a hook in a different language while one is already installed is rejected, so remove the existing hook with raptor delete hook first. The language comes from --language python|shell, or is inferred from the --script-file extension: .sh means Shell, anything else means Python. Use --script CODE instead of --script-file to pass the body inline.

delete removes the gate entirely, so releases that would have been held now proceed without approval. --yes skips the confirmation prompt. Without it, an interactive session prompts for confirmation; a non-interactive session such as CI or an agent does not wait, it fails immediately and tells you to re-run with --yes.

You can also manage the hook over the API. See the API Reference.