Creating a Policy to Warn When a Stack Is Not Using the Latest Version of a Spacelift-Hosted Module

Last updated: March 6, 2026

You can create an automated policy in Spacelift that warns whenever an OpenTofu/Terraform stack is not using the latest available version of a module.

This solution works by combining:

  • An after_plan hook that queries the Spacelift module registry to determine the latest version of each module used in the configuration.

  • A Plan Policy that compares the versions defined in your OpenTofu/Terraform configuration against the latest available versions.

If a module is outdated, the policy generates a warning during the plan phase.


Step 1: Create a Script to Query the Latest Module Versions

Create a script that reads the OpenTofu/Terraform configuration metadata produced by Spacelift and queries the module registry to determine the latest available version of each module.

It is recommended to place this script in a Spacelift context as a mounted file so it can be reused across multiple stacks.

Save the following script as:

query-module-versions.sh
#!/bin/sh
set -eu

CONFIG_JSON="configuration.custom.spacelift.json"
OUT_JSON="module_versions.custom.spacelift.json"
PLAN_FILE="spacelift.plan"

# Spacelift module registry API (Terraform registry protocol)
MODULE_API_BASE="https://app.spacelift.io/registry/modules/v1"

# Optional auth header
AUTH_HEADER=""
if [ -n "${SPACELIFT_API_TOKEN:-}" ]; then
  AUTH_HEADER="Authorization: Bearer ${SPACELIFT_API_TOKEN}"
fi

# Export configuration from the generated plan
if command -v tofu >/dev/null 2>&1; then
  tofu show -json "$PLAN_FILE" | jq -c '.configuration' > "$CONFIG_JSON"
elif command -v terraform >/dev/null 2>&1; then
  terraform show -json "$PLAN_FILE" | jq -c '.configuration' > "$CONFIG_JSON"
else
  echo "ERROR: neither tofu nor terraform is installed" >&2
  exit 1
fi

echo '{}' > "$OUT_JSON"

# Collect unique module sources from the root module
jq -r '.root_module.module_calls[]?.source // empty' "$CONFIG_JSON" | sort -u | while read -r SRC; do
  # Expect registry-style sources like:
  # spacelift.io/org/name/provider
  NS=$(echo "$SRC" | cut -d'/' -f2)
  NAME=$(echo "$SRC" | cut -d'/' -f3)
  PROVIDER=$(echo "$SRC" | cut -d'/' -f4)

  # Skip anything that doesn't look like a registry source
  if [ -z "$NS" ] || [ -z "$NAME" ] || [ -z "$PROVIDER" ]; then
    echo "Skipping non-registry module source: $SRC" >&2
    continue
  fi

  URL="${MODULE_API_BASE}/${NS}/${NAME}/${PROVIDER}/versions"
  echo "Querying latest version for ${SRC} -> ${URL}" >&2

  if [ -n "$AUTH_HEADER" ]; then
    RESP=$(curl -sS -H "$AUTH_HEADER" "$URL" || true)
  else
    RESP=$(curl -sS "$URL" || true)
  fi

  # Pick the highest semantic version as "latest"
  LATEST=$(echo "$RESP" \
    | jq -r '.modules[0].versions[].version' 2>/dev/null \
    | sort -Vr \
    | head -n1)

  if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then
    echo "WARN: could not determine latest version for ${SRC}; response: $RESP" >&2
    continue
  fi

  echo "Latest version for ${SRC} is ${LATEST}" >&2

  TMP="$(mktemp)"
  jq --arg src "$SRC" --arg ver "$LATEST" '. + {($src): $ver}' "$OUT_JSON" > "$TMP"
  mv "$TMP" "$OUT_JSON"
done

This script:

  • Extracts module sources from the OpenTofu/Terraform configuration

  • Queries the Spacelift module registry

  • Determines the latest version available for each module

  • Writes the results to a JSON file that becomes available to policies


Step 2: Configure API Authentication

The script needs access to the Spacelift Module Registry API.

Create a Spacelift API key, then add it as an environment variable in the context.

Environment variable:

Name: SPACELIFT_API_TOKEN
Value: <your API token>

Step 3: Add the after_plan Hook

Add the script as an after_plan hook so it runs after OpenTofu/Terraform generates the plan.

Hook command:

/mnt/workspace/query-module-versions.sh

Running this hook produces a metadata file named:

module_versions.custom.spacelift.json

This file is automatically exposed to policies under:

input.third_party_metadata.custom.module_versions

Step 4: Create the Plan Policy

Create a new Plan Policy in Spacelift using the following Rego code.

This policy compares the module versions used in your configuration against the latest versions retrieved by the script.

package spacelift

latest_module_versions := input.third_party_metadata.custom.module_versions

tracked_module_calls contains module if {
	some name
	module := input.third_party_metadata.custom.configuration.root_module.module_calls[name]
	required_version := latest_module_versions[module.source]
	required_version != ""
}

# Warn if no version is specified
warn contains reason if {
	module := tracked_module_calls[_]
	not module.version_constraint

	reason := sprintf(
		"Module source '%s' must pin a version, but no version was found in the module block.",
		[module.source],
	)
}

warn contains reason if {
	module := tracked_module_calls[_]
	module.version_constraint == ""

	reason := sprintf(
		"Module source '%s' must pin a version, but version_constraint is empty.",
		[module.source],
	)
}

# Warn if the version constraint is not valid SemVer
warn contains reason if {
	module := tracked_module_calls[_]
	required_version := latest_module_versions[module.source]
	current_version := module.version_constraint

	not semver.is_valid(current_version)

	reason := sprintf(
		"Module source '%s' has non-SemVer version constraint '%s'. Latest available version is %s.",
		[module.source, current_version, required_version],
	)
}

# Warn if the module version is older than the latest available version
warn contains reason if {
	module := tracked_module_calls[_]
	required_version := latest_module_versions[module.source]
	current_version := module.version_constraint

	semver.is_valid(required_version)
	semver.is_valid(current_version)
	semver.compare(current_version, required_version) < 0

	reason := sprintf(
		"Module source '%s' should use version '%s' or newer, but version '%s' was found.",
		[module.source, required_version, current_version],
	)
}

Step 5: Attach the Context and Policy to Your Stack

Attach both the context and the plan policy to your stacks.

A common approach is to use an autoattach label so that:

  • Any stack with the matching label automatically receives the context

  • The policy is automatically applied

This allows you to enforce module version checks consistently across many stacks.


The Result

image.png

How the Solution Works

When a stack run executes:

  1. OpenTofu/Terraform generates the plan.

  2. The after_plan hook runs the script.

  3. The script queries the Spacelift module registry and determines the latest versions of all modules used in the configuration.

  4. The script outputs the results as metadata.

  5. The Plan Policy reads this metadata and compares it with the versions used in the OpenTofu/Terraform configuration.

If a module version is outdated, a warning appears in the run output, for example:

Module source 'spacelift.io/org/module/provider' should use version '1.2.3' or newer, but version '1.2.0' was found.

Optional: Enforcing Instead of Warning

This policy currently generates warnings only, which means runs will still proceed.

If you want to block deployments using outdated modules, replace:

warn[reason]

with

deny[reason]

in the policy rules.

This will cause the run to fail whenever an outdated module version is detected.