#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
mode=${1:-check}
staging_project=${OPENAGENTS_STAGING_PROJECT_ID:-}
production_project=${OPENAGENTS_PRODUCTION_PROJECT_ID:-}
region=${OPENAGENTS_STAGING_REGION:-us-central1}
repository_id=openagents-staging
platform=linux/amd64
: "${staging_project:?OPENAGENTS_STAGING_PROJECT_ID is required}"
: "${production_project:?OPENAGENTS_PRODUCTION_PROJECT_ID is required}"
case "$mode" in
check) ;;
--publish) ;;
*) echo "usage: ops/staging/publish-candidate.sh [check|--publish]" >&2; exit 64 ;;
esac
case "$staging_project" in
*stag*) ;;
*) echo "staging project ID must contain 'stag'" >&2; exit 1 ;;
esac
if [ "$staging_project" = "$production_project" ]; then
echo "staging and production project IDs must differ" >&2
exit 1
fi
for command_name in docker gcloud jq sha256sum; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required" >&2
exit 1
fi
done
cd "$repo_root"
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
echo "candidate publication requires a clean worktree" >&2
exit 1
fi
git_sha=$(git rev-parse --verify HEAD)
origin_main=$(git rev-parse --verify refs/remotes/origin/main)
source_date_epoch=$(git show -s --format=%ct "$git_sha")
release_version=$(tr -d '\n' <"$repo_root/VERSION")
if [ "$git_sha" != "$origin_main" ]; then
echo "candidate must equal the locally fetched origin/main commit" >&2
exit 1
fi
ops/ci/gate.sh --verify
gcloud auth print-access-token >/dev/null
repository_json=$(
gcloud artifacts repositories describe "$repository_id" \
--project="$staging_project" \
--location="$region" \
--format=json
)
echo "$repository_json" | jq -e '
.format == "DOCKER" and
.dockerConfig.immutableTags == true
' >/dev/null || {
echo "staging Artifact Registry must be Docker format with immutable tags" >&2
exit 1
}
if [ "$mode" = check ]; then
echo "Candidate publication preflight passed for $git_sha"
exit 0
fi
registry_host="${region}-docker.pkg.dev"
repository="${registry_host}/${staging_project}/${repository_id}"
application_repository="${repository}/openagents"
builder_repository="${repository}/openagents-builder"
application_tag="${application_repository}:${git_sha}"
builder_tag="${builder_repository}:${git_sha}"
git_common_dir=$(git -C "$repo_root" rev-parse --path-format=absolute --git-common-dir)
evidence_root="$git_common_dir/openagents/staging-candidates"
candidate_path="$evidence_root/$git_sha"
run_root=$(mktemp -d /tmp/openagents-staging-candidate.XXXXXX)
candidate_temp=
builder_container=
cleanup() {
if [ -n "$builder_container" ]; then
docker rm "$builder_container" >/dev/null 2>&1 || true
fi
if [ -n "$candidate_temp" ] && [ -d "$candidate_temp" ]; then
find "$candidate_temp" -depth -delete 2>/dev/null || true
fi
find "$run_root" -depth -delete 2>/dev/null || true
}
trap cleanup EXIT INT TERM
if [ -e "$candidate_path" ]; then
if jq -e --arg sha "$git_sha" '.git_sha == $sha' \
"$candidate_path/candidate-manifest.json" >/dev/null 2>&1; then
echo "Candidate evidence already exists for $git_sha"
exit 0
fi
echo "candidate evidence path exists without a valid exact-SHA manifest" >&2
exit 1
fi
umask 077
mkdir -p "$evidence_root"
candidate_temp=$(mktemp -d "$evidence_root/.candidate.$git_sha.XXXXXX")
gcloud auth configure-docker "$registry_host" --quiet >/dev/null
remote_digest() {
image_tag=$1
receipt_name=$2
descriptor="$run_root/$receipt_name.descriptor.json"
inspect_error="$run_root/$receipt_name.inspect-error"
if docker buildx imagetools inspect "$image_tag" \
--format '{{json .Manifest}}' >"$descriptor" 2>"$inspect_error"; then
if jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))' "$descriptor"; then
return 0
fi
echo "registry returned an invalid manifest descriptor for $image_tag" >&2
cat "$descriptor" >&2
return 2
fi
if grep -Eiq 'not found|manifest unknown|does not exist' "$inspect_error"; then
return 1
fi
cat "$inspect_error" >&2
return 2
}
verify_platform() {
image_reference=$1
operating_system=$(docker image inspect "$image_reference" --format '{{.Os}}')
architecture=$(docker image inspect "$image_reference" --format '{{.Architecture}}')
if [ "$operating_system/$architecture" != "$platform" ]; then
echo "$image_reference resolved to $operating_system/$architecture, expected $platform" >&2
return 1
fi
return 0
}
if application_digest=$(remote_digest "$application_tag" application); then
echo "Reusing immutable application tag for $git_sha"
else
remote_status=$?
if [ "$remote_status" -ne 1 ]; then
exit "$remote_status"
fi
OPENAGENTS_IMAGE_PLATFORM="$platform" ops/deploy/build-image.sh "$application_tag"
docker push "$application_tag" >/dev/null
application_digest=$(remote_digest "$application_tag" application)
fi
if builder_digest=$(remote_digest "$builder_tag" builder); then
echo "Reusing immutable builder tag for $git_sha"
else
remote_status=$?
if [ "$remote_status" -ne 1 ]; then
exit "$remote_status"
fi
builder_iid="$run_root/builder.iid"
docker build \
--platform "$platform" \
--build-arg "OPENAGENTS_BUILD_REVISION=$git_sha" \
--build-arg "OPENAGENTS_RELEASE_VSN=$release_version" \
--build-arg "SOURCE_DATE_EPOCH=$source_date_epoch" \
--iidfile "$builder_iid" \
--label "org.opencontainers.image.revision=$git_sha" \
--tag "$builder_tag" \
--target forge-builder \
"$repo_root"
docker push "$builder_tag" >/dev/null
builder_digest=$(remote_digest "$builder_tag" builder)
fi
case "$application_digest" in
sha256:????????????????????????????????????????????????????????????????) ;;
*) echo "application registry manifest digest is invalid" >&2; exit 1 ;;
esac
case "$builder_digest" in
sha256:????????????????????????????????????????????????????????????????) ;;
*) echo "builder registry manifest digest is invalid" >&2; exit 1 ;;
esac
application_image="${application_repository}@${application_digest}"
builder_image="${builder_repository}@${builder_digest}"
docker pull --platform "$platform" "$application_image" >/dev/null
docker pull --platform "$platform" "$builder_image" >/dev/null
verify_platform "$application_image"
verify_platform "$builder_image"
application_revision=$(
docker image inspect "$application_image" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
)
builder_revision=$(
docker image inspect "$builder_image" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}'
)
if [ "$application_revision" != "$git_sha" ] || [ "$builder_revision" != "$git_sha" ]; then
echo "registry image revision labels do not match the exact Git SHA" >&2
exit 1
fi
embedded_application_revision=$(
docker run --rm \
--entrypoint /bin/sh \
"$application_image" \
-c 'release_version=$(awk '\''{print $2}'\'' /app/releases/start_erl.data); /app/erts-*/bin/erl -boot_var RELEASE_LIB /app/lib -boot "/app/releases/$release_version/start_clean" -noshell -pa /app/lib/openagents-*/ebin -eval "io:put_chars('\''Elixir.OpenAgents.BuildInfo'\'':revision()), halt()."' \
| tail -n 1
)
embedded_builder_revision=$(
docker run --rm \
--entrypoint /bin/sh \
"$builder_image" \
-c 'elixir -pa /app/_build/prod/lib/openagents/ebin -e "IO.write(OpenAgents.BuildInfo.revision())"'
)
if [ "$embedded_application_revision" != "$git_sha" ] ||
[ "$embedded_builder_revision" != "$git_sha" ]; then
echo "registry image packaged revisions do not match the exact Git SHA" >&2
exit 1
fi
docker run --rm \
--entrypoint /bin/sh \
"$application_image" \
-c 'set -eu
test -x /usr/local/lib/codex-package/bin/codex
test -x /usr/local/lib/codex-package/bin/codex-code-mode-host
test -x /usr/local/lib/codex-package/codex-resources/bwrap
test -x /usr/local/lib/codex-package/codex-path/rg
/usr/local/lib/codex-package/bin/codex-code-mode-host --help >/dev/null'
application_config_digest=$(docker image inspect "$application_image" --format '{{.Id}}')
builder_config_digest=$(docker image inspect "$builder_image" --format '{{.Id}}')
archive_name=$(
docker run --rm \
--entrypoint /bin/sh \
"$builder_image" \
-c 'set -- /app/_build/prod/openagents-*.tar.gz; [ "$#" -eq 1 ]; basename "$1"'
)
case "$archive_name" in
openagents-*.tar.gz) ;;
*) echo "builder image does not contain one release archive" >&2; exit 1 ;;
esac
archive_version=${archive_name#openagents-}
archive_version=${archive_version%.tar.gz}
if [ "$archive_version" != "$release_version" ]; then
echo "builder archive version does not match VERSION" >&2
exit 1
fi
builder_container=$(docker create "$builder_image")
docker cp "$builder_container:/app/_build/prod/$archive_name" "$candidate_temp/$archive_name" >/dev/null
docker rm "$builder_container" >/dev/null
builder_container=
ops/staging/generate-sbom.sh \
"$application_image" \
"$candidate_temp/sbom.cdx.json"
release_sha256=$(sha256sum "$candidate_temp/$archive_name" | cut -d ' ' -f 1)
sbom_sha256=$(sha256sum "$candidate_temp/sbom.cdx.json" | cut -d ' ' -f 1)
sbom_receipt_sha256=$(sha256sum "$candidate_temp/sbom.cdx.json.receipt" | cut -d ' ' -f 1)
gate_receipt="$git_common_dir/openagents/release-gate-receipts/$git_sha.json"
gate_receipt_sha256=$(sha256sum "$gate_receipt" | cut -d ' ' -f 1)
dockerfile_sha256=$(sha256sum "$repo_root/Dockerfile" | cut -d ' ' -f 1)
mix_lock_sha256=$(sha256sum "$repo_root/mix.lock" | cut -d ' ' -f 1)
lineage_map_sha256=$(sha256sum "$repo_root/priv/migration_lineages/prior-2026-08-19.json" | cut -d ' ' -f 1)
application_spec_sha256=$(
docker run --rm \
--entrypoint /bin/sh \
"$builder_image" \
-c 'sha256sum /app/_build/prod/lib/openagents/ebin/openagents.app' \
| cut -d ' ' -f 1
)
elixir_version=$(docker run --rm --entrypoint elixir "$builder_image" --version | awk '/^Elixir / {print $2}')
otp_release=$(docker run --rm --entrypoint elixir "$builder_image" --version | awk '/^Erlang\/OTP / {print $2}')
erts_version=$(
docker run --rm --entrypoint erl "$builder_image" \
-noshell -eval 'io:put_chars(erlang:system_info(version)), halt().'
)
generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
manifest_temp="$candidate_temp/candidate-manifest.json.tmp"
jq -n \
--arg git_sha "$git_sha" \
--arg generated_at "$generated_at" \
--argjson source_date_epoch "$source_date_epoch" \
--arg staging_project "$staging_project" \
--arg region "$region" \
--arg platform "$platform" \
--arg application_tag "$application_tag" \
--arg application_image "$application_image" \
--arg application_digest "$application_digest" \
--arg application_config_digest "$application_config_digest" \
--arg builder_tag "$builder_tag" \
--arg builder_image "$builder_image" \
--arg builder_digest "$builder_digest" \
--arg builder_config_digest "$builder_config_digest" \
--arg release_version "$release_version" \
--arg release_file "$archive_name" \
--arg release_sha256 "$release_sha256" \
--arg sbom_sha256 "$sbom_sha256" \
--arg sbom_receipt_sha256 "$sbom_receipt_sha256" \
--arg gate_receipt_sha256 "$gate_receipt_sha256" \
--arg dockerfile_sha256 "$dockerfile_sha256" \
--arg mix_lock_sha256 "$mix_lock_sha256" \
--arg lineage_map_sha256 "$lineage_map_sha256" \
--arg elixir_version "$elixir_version" \
--arg otp_release "$otp_release" \
--arg erts_version "$erts_version" \
--arg application_spec_sha256 "$application_spec_sha256" '
{
schema: "openagents.staging-candidate.v1",
git_sha: $git_sha,
branch: "main",
generated_at: $generated_at,
source_date_epoch: $source_date_epoch,
target: {
environment: "staging",
project: $staging_project,
region: $region,
platform: $platform,
immutable_tags: true
},
images: {
application: {
tag: $application_tag,
reference: $application_image,
manifest_digest: $application_digest,
config_digest: $application_config_digest
},
builder: {
tag: $builder_tag,
reference: $builder_image,
manifest_digest: $builder_digest,
config_digest: $builder_config_digest
}
},
release: {
version: $release_version,
file: $release_file,
sha256: $release_sha256
},
sbom: {
file: "sbom.cdx.json",
sha256: $sbom_sha256,
receipt_sha256: $sbom_receipt_sha256
},
receipts: {
release_gate_sha256: $gate_receipt_sha256
},
inputs: {
dockerfile_sha256: $dockerfile_sha256,
mix_lock_sha256: $mix_lock_sha256,
migration_lineage_map_sha256: $lineage_map_sha256,
application_spec_sha256: $application_spec_sha256
},
toolchain: {
elixir: $elixir_version,
otp: $otp_release,
erts: $erts_version
}
}
' >"$manifest_temp"
mv "$manifest_temp" "$candidate_temp/candidate-manifest.json"
manifest_sha256=$(sha256sum "$candidate_temp/candidate-manifest.json" | cut -d ' ' -f 1)
printf '%s candidate-manifest.json\n' "$manifest_sha256" \
>"$candidate_temp/candidate-manifest.sha256"
jq -e \
--arg sha "$git_sha" \
--arg application_digest "$application_digest" \
--arg builder_digest "$builder_digest" '
.schema == "openagents.staging-candidate.v1" and
.git_sha == $sha and
.target.environment == "staging" and
.target.immutable_tags == true and
.images.application.manifest_digest == $application_digest and
.images.builder.manifest_digest == $builder_digest and
(.release.sha256 | test("^[0-9a-f]{64}$")) and
(.sbom.sha256 | test("^[0-9a-f]{64}$"))
' "$candidate_temp/candidate-manifest.json" >/dev/null
mv "$candidate_temp" "$candidate_path"
candidate_temp=
trap - EXIT INT TERM
find "$run_root" -depth -delete
echo "Published immutable staging candidate $git_sha"
echo "Application: $application_image"
echo "Builder: $builder_image"
echo "Evidence: .git/openagents/staging-candidates/$git_sha"