#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
required_stages='compile production_compile precommit enumeration_proofs cluster javascript direct_transaction relup_topology relup version_chain interrupted_install rolling_replacement contracts staging_infra release_smoke'
mode=${1:-run}
if [ "$(git -C "$repo_root" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then
echo "release gate must run from a Git worktree" >&2
exit 1
fi
git_common_dir=$(git -C "$repo_root" rev-parse --path-format=absolute --git-common-dir)
receipt_root="$git_common_dir/openagents/release-gate-receipts"
git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
receipt_path="$receipt_root/$git_sha.json"
verify_receipt() {
[ -f "$receipt_path" ] || return 1
jq -e \
--arg sha "$git_sha" \
--arg stages "$required_stages" '
.schema == "openagents.release-gate.v1" and
.git_sha == $sha and
.status == "passed" and
([($stages | split(" "))[] as $stage | .stages[$stage].status == "passed"] | all)
' "$receipt_path" >/dev/null
}
case "$mode" in
--verify)
if verify_receipt; then
echo "Release gate receipt is valid for $git_sha"
exit 0
fi
echo "no complete release gate receipt exists for $git_sha" >&2
exit 1
;;
--force | run) ;;
*)
echo "usage: ops/ci/gate.sh [--force|--verify]" >&2
exit 64
;;
esac
if [ "$mode" != "--force" ] && verify_receipt; then
echo "Release gate already passed for $git_sha"
exit 0
fi
if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=all)" ]; then
echo "release gate requires a clean worktree" >&2
exit 1
fi
if [ "${OPENAGENTS_RELEASE_SMOKE_DISPOSABLE:-}" != "1" ]; then
echo "set OPENAGENTS_RELEASE_SMOKE_DISPOSABLE=1 for a disposable database" >&2
exit 1
fi
if [ -z "${OPENAGENTS_RELEASE_SMOKE_DATABASE_URL:-}" ]; then
echo "OPENAGENTS_RELEASE_SMOKE_DATABASE_URL is required" >&2
exit 1
fi
for command_name in jq mix npm rg; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required for the release gate" >&2
exit 1
fi
done
# The asset dependencies. A release worktree is created fresh, `assets/node_modules`
# is not tracked, and nothing else in the gate installs it — so `mix esbuild`
# fails to resolve an import eight stages in and reports a missing package as a
# deploy blocker. Installed here, before any stage, because a gate that cannot
# build the assets has not tested the release.
if [ ! -d "$repo_root/assets/node_modules" ]; then
echo "Installing asset dependencies"
(cd "$repo_root/assets" && npm ci --no-audit --no-fund) || {
echo "asset dependencies could not be installed" >&2
exit 1
}
fi
run_root=$(mktemp -d /tmp/openagents-release-gate.XXXXXX)
started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
started_epoch=$(date +%s)
cleanup() {
find "$run_root" -depth -delete
}
trap cleanup EXIT INT TERM
# A stage's inputs, as paths. A stage is re-run when any of them changes and
# reused when none of them has, so a test-only edit stops rebuilding releases
# and re-running the asset suite. Everything depends on the lockfile and the
# configuration, so those are added to every stage rather than listed here.
#
# The rule for editing this list is one-directional: adding a path is always
# safe, and removing one can make the gate reuse a stage that should have run.
# A stage whose inputs are not listed gets the whole worktree and therefore
# never reuses, which is the safe default rather than an oversight.
stage_inputs() {
case $1 in
compile | production_compile) echo "lib" ;;
# `mix precommit` runs the documentation and reference checks as well as
# the suite, and those read the invariant ledger and the docs tree. Given
# the whole worktree it never reuses, which is the right answer for the
# broad stage: the savings are meant to come from the narrow ones.
precommit) echo "." ;;
enumeration_proofs) echo "." ;;
cluster | direct_transaction | relup_topology | rolling_replacement) echo "lib test" ;;
javascript) echo "assets" ;;
relup | version_chain | interrupted_install) echo "lib priv assets ops" ;;
contracts) echo "lib priv ops" ;;
staging_infra) echo "ops" ;;
release_smoke) echo "lib priv assets ops" ;;
*) echo "." ;;
esac
}
# The identity of a stage's inputs: Git's own tree hashes, so a path that has
# not changed hashes the same however it got there, and an uncommitted edit
# hashes differently from the commit it sits on. The gate already refuses a
# dirty worktree, so HEAD is the whole truth here.
stage_key() {
stage_name=$1
{
echo "$stage_name"
for input_path in $(stage_inputs "$stage_name") mix.exs mix.lock config; do
git -C "$repo_root" rev-parse "HEAD:$input_path" 2>/dev/null ||
echo "absent:$input_path"
done
} | shasum -a 256 | cut -d" " -f1
}
stage_cache_root="$git_common_dir/openagents/release-gate-stages"
run_stage() {
stage_name=$1
shift
stage_log="$run_root/$stage_name.log"
stage_started=$(date +%s)
cache_entry="$stage_cache_root/$stage_name.$(stage_key "$stage_name")"
if [ "$mode" != "--force" ] && [ -f "$cache_entry" ]; then
echo "Reusing $stage_name (inputs unchanged since it last passed)"
eval "${stage_name}_duration_seconds=$(cat "$cache_entry")"
return 0
fi
echo "Running $stage_name"
set +e
"$@" >"$stage_log" 2>&1
stage_status=$?
set -e
cat "$stage_log"
if [ "$stage_status" -ne 0 ]; then
echo "$stage_name failed" >&2
exit "$stage_status"
fi
stage_finished=$(date +%s)
eval "${stage_name}_duration_seconds=$((stage_finished - stage_started))"
# Recorded only after it passed, so a failure is never reused.
mkdir -p "$stage_cache_root"
eval "echo \$${stage_name}_duration_seconds" >"$cache_entry"
}
cd "$repo_root"
run_stage compile env MIX_ENV=test mix compile --warnings-as-errors
run_stage production_compile env MIX_ENV=prod mix compile --warnings-as-errors
run_stage precommit env MIX_ENV=test mix precommit
run_stage enumeration_proofs env MIX_ENV=test sh ops/ci/enumeration-proofs.sh
run_stage cluster env MIX_ENV=test mix test --warnings-as-errors --only cluster
run_stage javascript env MIX_ENV=test npm --prefix assets test
run_stage direct_transaction env MIX_ENV=test mix test --warnings-as-errors \
test/openagents/forge/deployment_node_test.exs \
test/openagents/forge/deployment_cluster_test.exs \
test/openagents/forge/hot_loader_test.exs \
test/openagents/forge/boot_converge_test.exs
run_stage relup_topology env MIX_ENV=test mix test --warnings-as-errors \
test/openagents/forge/relup_topology_test.exs \
test/openagents/forge/relup_deployment_test.exs \
test/openagents/forge/relup_node_test.exs
run_stage relup ops/relup-proof/run.sh
run_stage version_chain env \
OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
OPENAGENTS_RELUP_PROOF_DATABASE_URL="$OPENAGENTS_RELEASE_SMOKE_DATABASE_URL" \
ops/relup-proof/version-chain.sh
run_stage interrupted_install env \
OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
OPENAGENTS_RELUP_PROOF_DATABASE_URL="$OPENAGENTS_RELEASE_SMOKE_DATABASE_URL" \
ops/relup-proof/kill-during-install.sh
run_stage rolling_replacement env MIX_ENV=test mix test --warnings-as-errors \
test/openagents/forge/rolling_replacement_test.exs \
test/openagents/forge/rolling_boot_convergence_test.exs
run_stage contracts ops/ci/contracts.sh
run_stage staging_infra ops/ci/staging-infra.sh
run_stage release_smoke ops/ci/release-smoke.sh
if [ "$(git rev-parse --verify HEAD)" != "$git_sha" ]; then
echo "Git HEAD changed while the release gate was running" >&2
exit 1
fi
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
echo "release gate left the worktree dirty" >&2
exit 1
fi
completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
completed_epoch=$(date +%s)
total_duration_seconds=$((completed_epoch - started_epoch))
receipt_temp="$receipt_path.tmp.$$"
mkdir -p "$receipt_root"
umask 077
cat >"$receipt_temp" <<EOF
{
"schema": "openagents.release-gate.v1",
"git_sha": "$git_sha",
"status": "passed",
"started_at": "$started_at",
"completed_at": "$completed_at",
"total_duration_seconds": $total_duration_seconds,
"automatic_retries": 0,
"stages": {
"compile": {"status": "passed", "duration_seconds": $compile_duration_seconds},
"production_compile": {"status": "passed", "duration_seconds": $production_compile_duration_seconds},
"precommit": {"status": "passed", "duration_seconds": $precommit_duration_seconds},
"enumeration_proofs": {"status": "passed", "duration_seconds": $enumeration_proofs_duration_seconds},
"cluster": {"status": "passed", "duration_seconds": $cluster_duration_seconds},
"javascript": {"status": "passed", "duration_seconds": $javascript_duration_seconds},
"direct_transaction": {"status": "passed", "duration_seconds": $direct_transaction_duration_seconds},
"relup_topology": {"status": "passed", "duration_seconds": $relup_topology_duration_seconds},
"relup": {"status": "passed", "duration_seconds": $relup_duration_seconds},
"version_chain": {"status": "passed", "duration_seconds": $version_chain_duration_seconds},
"interrupted_install": {"status": "passed", "duration_seconds": $interrupted_install_duration_seconds},
"rolling_replacement": {"status": "passed", "duration_seconds": $rolling_replacement_duration_seconds},
"contracts": {"status": "passed", "duration_seconds": $contracts_duration_seconds},
"staging_infra": {"status": "passed", "duration_seconds": $staging_infra_duration_seconds},
"release_smoke": {"status": "passed", "duration_seconds": $release_smoke_duration_seconds}
}
}
EOF
mv "$receipt_temp" "$receipt_path"
echo "Release gate passed for $git_sha"
echo "Receipt: .git/openagents/release-gate-receipts/$git_sha.json"