Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/scripts/cleanup-localnet-ci-images.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env bash

set -euo pipefail

select_candidates() {
local versions_file="$1"
local cutoff="$2"

jq -c --arg cutoff "$cutoff" '
.[]
| select(.created_at < $cutoff)
| select(
(.metadata.container.tags | length) == 0
or all(.metadata.container.tags[]; test("^ci-[0-9a-f]{40}$"))
)
' "$versions_file"
}

cleanup_package() {
: "${PACKAGE_OWNER:?PACKAGE_OWNER is required}"
: "${GH_TOKEN:?GH_TOKEN is required}"

local package_name="subtensor-localnet-ci"
local retention_days="${RETENTION_DAYS:-30}"
local temp_dir="${RUNNER_TEMP:-/tmp}"
local versions candidates cutoff deleted version id tags created_at

[[ "$retention_days" =~ ^[0-9]+$ ]] || {
echo "RETENTION_DAYS must be a non-negative integer: $retention_days" >&2
return 1
}

versions="$(mktemp "$temp_dir/localnet-ci-versions.XXXXXX")"
candidates="$(mktemp "$temp_dir/localnet-ci-candidates.XXXXXX")"
trap "rm -f '$versions' '$candidates'" EXIT
cutoff="$(date -u -d "$retention_days days ago" +%Y-%m-%dT%H:%M:%SZ)"

gh api --paginate \
"/orgs/$PACKAGE_OWNER/packages/container/$package_name/versions?per_page=100" \
| jq -s 'add // []' >"$versions"
select_candidates "$versions" "$cutoff" >"$candidates"

deleted=0
while IFS= read -r version; do
id="$(jq -r '.id' <<<"$version")"
tags="$(jq -r '.metadata.container.tags | join(",")' <<<"$version")"
created_at="$(jq -r '.created_at' <<<"$version")"
echo "Deleting version $id (tags=${tags:-untagged}, created=$created_at)"
gh api --method DELETE \
"/orgs/$PACKAGE_OWNER/packages/container/$package_name/versions/$id"
deleted=$((deleted + 1))
done <"$candidates"

echo "Deleted $deleted expired $package_name image version(s)."
}

case "${1:-cleanup}" in
select)
[[ $# -eq 3 ]] || {
echo "usage: $0 select VERSIONS_FILE CUTOFF" >&2
exit 2
}
select_candidates "$2" "$3"
;;
cleanup)
[[ $# -eq 0 || $# -eq 1 ]] || {
echo "usage: $0 [cleanup]" >&2
exit 2
}
cleanup_package
;;
*)
echo "usage: $0 [cleanup] | select VERSIONS_FILE CUTOFF" >&2
exit 2
;;
esac
62 changes: 62 additions & 0 deletions .github/scripts/publish-localnet-manifest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash

set -euo pipefail

: "${IMAGE:?IMAGE is required}"
: "${TAG:?TAG is required}"
: "${SHA:?SHA is required}"
: "${PUBLISH_LATEST:?PUBLISH_LATEST is required}"

descriptor_dir="${1:-image-descriptors}"

[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "SHA must be a full lowercase commit SHA: $SHA" >&2
exit 1
}

read_source() {
local arch="$1"
local descriptor="$descriptor_dir/$arch.txt"
local source digest

[[ -s "$descriptor" ]] || {
echo "missing $arch image descriptor: $descriptor" >&2
return 1
}

IFS= read -r source < "$descriptor"
digest="${source#"$IMAGE@"}"
[[ "$source" == "$IMAGE@$digest" && "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "invalid $arch image descriptor: $source" >&2
return 1
}

printf '%s' "$source"
}

amd64_source="$(read_source amd64)"
arm64_source="$(read_source arm64)"

tags=(
--tag "$IMAGE:$TAG"
--tag "$IMAGE:sha-$SHA"
)
case "$PUBLISH_LATEST" in
true)
tags+=(--tag "$IMAGE:latest")
;;
false)
;;
*)
echo "PUBLISH_LATEST must be true or false: $PUBLISH_LATEST" >&2
exit 1
;;
esac

docker buildx imagetools create \
"${tags[@]}" \
--annotation "index:org.opencontainers.image.description=Subtensor local development network for CI and local testing" \
--annotation "index:org.opencontainers.image.source=https://github.com/RaoFoundation/subtensor" \
--annotation "index:org.opencontainers.image.licenses=Apache-2.0" \
"$amd64_source" \
"$arm64_source"
48 changes: 48 additions & 0 deletions .github/scripts/resolve-localnet-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash

set -euo pipefail

output_file="${1:-${GITHUB_OUTPUT:-}}"
: "${output_file:?pass an output file or set GITHUB_OUTPUT}"
: "${EVENT_NAME:?EVENT_NAME is required}"
: "${REF_NAME:?REF_NAME is required}"
: "${EVENT_SHA:?EVENT_SHA is required}"

branch_or_tag="${BRANCH_OR_TAG:-}"
pr_number="${PR_NUMBER:-}"
pr_head_sha="${PR_HEAD_SHA:-}"
pr_head_ref="${PR_HEAD_REF:-}"

sanitize_tag() {
local value="$1"
value="${value//[^a-zA-Z0-9._-]/-}"
if [[ ! "$value" =~ ^[a-zA-Z0-9_] ]]; then
value="ref-${value}"
fi
printf '%.128s' "$value"
}

if [[ -n "$pr_number" ]]; then
[[ "$pr_number" =~ ^[0-9]+$ ]] || {
echo "PR_NUMBER must contain digits only: $pr_number" >&2
exit 1
}
tag="pr-${pr_number}"
ref="${pr_head_sha:-${pr_head_ref:-${branch_or_tag:-main}}}"
latest_tag=false
else
source_tag="${branch_or_tag:-$REF_NAME}"
tag="$(sanitize_tag "$source_tag")"
ref="${branch_or_tag:-$EVENT_SHA}"
if [[ "$tag" == main ]]; then
latest_tag=true
else
latest_tag=false
fi
fi

{
echo "tag=$tag"
echo "ref=$ref"
echo "latest_tag=$latest_tag"
} >>"$output_file"
31 changes: 31 additions & 0 deletions .github/scripts/test-cleanup-localnet-ci-images.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash

set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cleanup_script="$repo_root/.github/scripts/cleanup-localnet-ci-images.sh"
fixture="$(mktemp)"
trap 'rm -f "$fixture"' EXIT

cat >"$fixture" <<'JSON'
[
{"id":1,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":[]}}},
{"id":2,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["ci-1111111111111111111111111111111111111111"]}}},
{"id":3,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["ci-2222222222222222222222222222222222222222","ci-3333333333333333333333333333333333333333"]}}},
{"id":4,"created_at":"2026-06-16T00:00:00Z","metadata":{"container":{"tags":["ci-4444444444444444444444444444444444444444"]}}},
{"id":5,"created_at":"2026-07-01T00:00:00Z","metadata":{"container":{"tags":["ci-5555555555555555555555555555555555555555"]}}},
{"id":6,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["ci-short"]}}},
{"id":7,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["ci-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]}}},
{"id":8,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["ci-8888888888888888888888888888888888888888","main"]}}},
{"id":9,"created_at":"2026-06-01T00:00:00Z","metadata":{"container":{"tags":["main"]}}}
]
JSON

actual="$($cleanup_script select "$fixture" 2026-06-16T00:00:00Z | jq -r '.id' | paste -sd, -)"
expected="1,2,3"
[[ "$actual" == "$expected" ]] || {
echo "unexpected cleanup candidates: expected=$expected actual=$actual" >&2
exit 1
}

echo "localnet CI image cleanup policy tests passed"
158 changes: 158 additions & 0 deletions .github/scripts/test-localnet-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env bash

set -euo pipefail

image="${1:?usage: test-localnet-image.sh IMAGE}"
suffix="${GITHUB_RUN_ID:-local}-$$"
fast_container="subtensor-localnet-fast-${suffix}"
standard_container="subtensor-localnet-standard-${suffix}"
persistent_container="subtensor-localnet-persistent-${suffix}"
failure_container="subtensor-localnet-failure-${suffix}"
state_volume="subtensor-localnet-state-${suffix}"

cleanup() {
docker rm -f "$fast_container" "$standard_container" "$persistent_container" \
"$failure_container" \
>/dev/null 2>&1 || true
docker volume rm -f "$state_volume" >/dev/null 2>&1 || true
}
trap cleanup EXIT

show_failure() {
local container="$1"
echo "--- $container logs ---" >&2
docker logs "$container" >&2 || true
echo "--- $container inspect ---" >&2
docker inspect "$container" >&2 || true
}

wait_healthy() {
local container="$1"
local attempts="${2:-90}"

for ((attempt = 1; attempt <= attempts; attempt++)); do
local running health
running="$(docker inspect --format '{{.State.Running}}' "$container")"
health="$(docker inspect --format '{{.State.Health.Status}}' "$container")"
if [[ "$running" != true ]]; then
show_failure "$container"
return 1
fi
if [[ "$health" == healthy ]]; then
return 0
fi
if [[ "$health" == unhealthy ]]; then
show_failure "$container"
return 1
fi
sleep 2
done

show_failure "$container"
return 1
}

assert_rpc() {
local container="$1"
local port="$2"
docker exec "$container" /scripts/localnet_healthcheck.sh "$port"
}

block_number() {
local container="$1"
local response hex
response="$(
docker exec "$container" curl --fail --silent --max-time 2 \
-H 'content-type: application/json' \
--data '{"id":1,"jsonrpc":"2.0","method":"chain_getHeader","params":[]}' \
http://127.0.0.1:9944
)"
hex="$(sed -n 's/.*"number":"0x\([0-9a-fA-F]*\)".*/\1/p' <<<"$response")"
[[ -n "$hex" ]] || {
echo "could not parse block number from: $response" >&2
return 1
}
printf '%d\n' "$((16#$hex))"
}

block_hash() {
local container="$1"
local number="$2"
local response hash
response="$(
docker exec "$container" curl --fail --silent --max-time 2 \
-H 'content-type: application/json' \
--data "{\"id\":1,\"jsonrpc\":\"2.0\",\"method\":\"chain_getBlockHash\",\"params\":[$number]}" \
http://127.0.0.1:9944
)"
hash="$(sed -n 's/.*"result":"\(0x[0-9a-fA-F]*\)".*/\1/p' <<<"$response")"
[[ -n "$hash" ]] || {
echo "could not parse block hash from: $response" >&2
return 1
}
printf '%s\n' "$hash"
}

docker run --rm --entrypoint /target/fast-runtime/release/node-subtensor \
"$image" --help >/dev/null
docker run --rm --entrypoint /target/non-fast-runtime/release/node-subtensor \
"$image" --help >/dev/null

docker run -d --name "$fast_container" "$image" True >/dev/null
wait_healthy "$fast_container"
assert_rpc "$fast_container" 9944
assert_rpc "$fast_container" 9945
docker stop --time 40 "$fast_container" >/dev/null

docker run -d --name "$standard_container" "$image" False >/dev/null
wait_healthy "$standard_container"
assert_rpc "$standard_container" 9944
assert_rpc "$standard_container" 9945
docker stop --time 40 "$standard_container" >/dev/null

# The entrypoint supervises all three authorities. Kill the newest process
# (authority three), proving supervision is not accidentally limited to the
# first child.
docker run -d --name "$failure_container" "$image" True >/dev/null
wait_healthy "$failure_container"
docker exec "$failure_container" pkill -TERM -n node-subtensor
for _ in {1..30}; do
if [[ "$(docker inspect --format '{{.State.Running}}' "$failure_container")" == false ]]; then
break
fi
sleep 1
done
if [[ "$(docker inspect --format '{{.State.Running}}' "$failure_container")" != false ]]; then
show_failure "$failure_container"
exit 1
fi
if [[ "$(docker inspect --format '{{.State.ExitCode}}' "$failure_container")" == 0 ]]; then
echo "localnet container succeeded after an authority exited" >&2
exit 1
fi

docker volume create "$state_volume" >/dev/null
docker run -d --name "$persistent_container" -v "$state_volume:/tmp" \
"$image" True >/dev/null
wait_healthy "$persistent_container"
before_restart="$(block_number "$persistent_container")"
before_hash="$(block_hash "$persistent_container" "$before_restart")"
docker stop --time 40 "$persistent_container" >/dev/null
docker rm "$persistent_container" >/dev/null

docker run -d --name "$persistent_container" -v "$state_volume:/tmp" \
"$image" True --no-purge >/dev/null
wait_healthy "$persistent_container"
after_restart="$(block_number "$persistent_container")"
if ((after_restart < before_restart)); then
echo "--no-purge lost chain state: before=$before_restart after=$after_restart" >&2
exit 1
fi
after_hash="$(block_hash "$persistent_container" "$before_restart")"
if [[ "$after_hash" != "$before_hash" ]]; then
echo "--no-purge changed block $before_restart: before=$before_hash after=$after_hash" >&2
exit 1
fi

docker stop --time 40 "$persistent_container" >/dev/null
echo "localnet image compatibility smoke tests passed"
Loading
Loading