Z3 stack smoke test #21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Z3 stack smoke test | |
| # Build the Z3 stack images (zebra, zaino, zallet) from vendored source and | |
| # prove the deploy/z3-stack/ bundle works end to end: zebra healthy, zaino | |
| # serving lightwalletd gRPC, zallet answering wallet RPC with a node tip, no | |
| # crash-loops, nothing exposed that shouldn't be. None of the checks need a | |
| # synced chain, so the smoke phase takes minutes; the builds dominate and are | |
| # cached in GHCR (`:buildcache` refs, shared with images.yml, so the two | |
| # workflows keep each other warm). Registry cache has no 10 GiB Actions quota | |
| # and no 7-day expiry; the build job also warms zero-zcashd (not part of the | |
| # smoke stack) so release-day images.yml runs never start cold. | |
| # | |
| # Build definitions mirror deploy/z3-stack/docker-compose.build.yml; keep them | |
| # in sync if you change contexts, Dockerfiles, or build args. | |
| on: | |
| pull_request: | |
| paths: | |
| - "deploy/z3-stack/**" | |
| - "zebra/docker/Dockerfile" | |
| - "zebra/docker/entrypoint.sh" | |
| - "zaino/Dockerfile" | |
| - "zaino/entrypoint.sh" | |
| - "zallet/Dockerfile" | |
| - ".github/workflows/z3-smoke.yml" | |
| workflow_dispatch: | |
| schedule: | |
| # Weekly: catches interop breakage from subtree updates that don't touch | |
| # the paths above. | |
| - cron: "17 6 * * 1" | |
| # packages: write is for pushing the GHCR build cache; on fork PRs GitHub | |
| # forces the token to read-only, which is fine because PR runs write the | |
| # ephemeral GHA cache instead (see the cache-to expression below). | |
| permissions: | |
| contents: read | |
| packages: write | |
| jobs: | |
| # Populate the build cache for the images in parallel, so the smoke job's | |
| # rebuilds are cache hits and its wall-clock is max(build) + smoke. | |
| build: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - name: zero-zebra | |
| context: zebra | |
| dockerfile: zebra/docker/Dockerfile | |
| build_args: | | |
| FEATURES=release_max_level_info,progress-bar,prometheus | |
| - name: zero-zaino | |
| context: zaino | |
| dockerfile: zaino/Dockerfile | |
| build_args: | | |
| NO_TLS=true | |
| rust_toolchain_file: zaino/rust-toolchain.toml | |
| - name: zero-zallet | |
| context: zallet | |
| dockerfile: zallet/Dockerfile | |
| build_args: | | |
| FEATURES=zaino,rpc-cli,zcashd-import | |
| # Not part of the z3 stack; built here only to keep its build cache | |
| # warm (registry cache entries are only refreshed when read, and | |
| # zcashd is otherwise built solely on releases). A warm rebuild is | |
| # seconds; a cold one repopulates off the release critical path. | |
| - name: zero-zcashd | |
| context: zcashd | |
| dockerfile: docker/zcashd/Dockerfile | |
| build_args: "" | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| # Needed to push the GHCR build cache; pulls of the public cache refs | |
| # are anonymous. On fork PRs the token is read-only, but those runs | |
| # don't write to the registry (see cache-to below). | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve build args | |
| id: args | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "build_args<<BUILD_ARGS_EOF" | |
| printf '%s\n' "${{ matrix.build_args }}" | |
| if [ -n "${{ matrix.rust_toolchain_file }}" ]; then | |
| rv=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ | |
| "${{ matrix.rust_toolchain_file }}" | head -n1) | |
| if ! [[ "$rv" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then | |
| echo "::error::unexpected channel '${rv}' in ${{ matrix.rust_toolchain_file }}" | |
| exit 1 | |
| fi | |
| echo "RUST_VERSION=${rv}" | |
| fi | |
| echo "BUILD_ARGS_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| # Cache refs are hardcoded lowercase: registry references reject the | |
| # uppercase in github.repository_owner. PR runs can't push packages | |
| # (read-only token on forks), so they write the ephemeral GHA cache | |
| # instead; every run reads both, so PR builds still start warm. | |
| - name: Build (cache only) | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ${{ matrix.context }} | |
| file: ${{ matrix.dockerfile }} | |
| target: runtime | |
| platforms: linux/amd64 | |
| push: false | |
| build-args: ${{ steps.args.outputs.build_args }} | |
| cache-from: | | |
| type=registry,ref=ghcr.io/shieldedlabs/${{ matrix.name }}:buildcache | |
| type=gha,scope=${{ matrix.name }} | |
| cache-to: ${{ github.event_name == 'pull_request' && format('type=gha,mode=max,scope={0}', matrix.name) || format('type=registry,ref=ghcr.io/shieldedlabs/{0}:buildcache,mode=max,image-manifest=true', matrix.name) }} | |
| smoke: | |
| needs: build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| env: | |
| ZERO_IMAGE_TAG: smoke | |
| defaults: | |
| run: | |
| working-directory: deploy/z3-stack | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 1 | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Resolve zaino RUST_VERSION | |
| id: zaino_args | |
| working-directory: . | |
| run: | | |
| set -euo pipefail | |
| rv=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ | |
| zaino/rust-toolchain.toml | head -n1) | |
| echo "rust_version=${rv}" >> "$GITHUB_OUTPUT" | |
| # Rebuilds are cache hits from the build job; load the images locally | |
| # under the :smoke tag that ZERO_IMAGE_TAG points the compose file at. | |
| - name: Load zebra image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: zebra | |
| file: zebra/docker/Dockerfile | |
| target: runtime | |
| platforms: linux/amd64 | |
| load: true | |
| tags: ghcr.io/shieldedlabs/zero-zebra:smoke | |
| build-args: | | |
| FEATURES=release_max_level_info,progress-bar,prometheus | |
| cache-from: | | |
| type=registry,ref=ghcr.io/shieldedlabs/zero-zebra:buildcache | |
| type=gha,scope=zero-zebra | |
| - name: Load zaino image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: zaino | |
| file: zaino/Dockerfile | |
| target: runtime | |
| platforms: linux/amd64 | |
| load: true | |
| tags: ghcr.io/shieldedlabs/zero-zaino:smoke | |
| build-args: | | |
| NO_TLS=true | |
| RUST_VERSION=${{ steps.zaino_args.outputs.rust_version }} | |
| cache-from: | | |
| type=registry,ref=ghcr.io/shieldedlabs/zero-zaino:buildcache | |
| type=gha,scope=zero-zaino | |
| - name: Load zallet image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: zallet | |
| file: zallet/Dockerfile | |
| target: runtime | |
| platforms: linux/amd64 | |
| load: true | |
| tags: ghcr.io/shieldedlabs/zero-zallet:smoke | |
| build-args: | | |
| FEATURES=zaino,rpc-cli,zcashd-import | |
| cache-from: | | |
| type=registry,ref=ghcr.io/shieldedlabs/zero-zallet:buildcache | |
| type=gha,scope=zero-zallet | |
| # From here on this is the bundle's README walkthrough, scripted. | |
| - name: One-time wallet init (offline) | |
| run: | | |
| set -euxo pipefail | |
| docker run --rm -v "$PWD:/out" alpine:3.20 \ | |
| sh -c 'apk add --no-cache age >/dev/null && age-keygen -o /out/encryption-identity.txt' | |
| sudo chown 1000:1000 encryption-identity.txt | |
| sudo chmod 400 encryption-identity.txt | |
| # Deliberately before zebra exists: init must work offline. | |
| docker compose run --rm --no-deps zallet \ | |
| --datadir /var/lib/zallet --config /etc/zallet/zallet.toml init-wallet-encryption | |
| docker compose run --rm --no-deps zallet \ | |
| --datadir /var/lib/zallet --config /etc/zallet/zallet.toml generate-mnemonic | |
| - name: Start the stack | |
| run: | | |
| set -euxo pipefail | |
| docker compose up -d | |
| # zebra healthcheck gates zaino/zallet; it passes when RPC answers, | |
| # long before the chain is synced. | |
| deadline=$((SECONDS + 300)) | |
| until [ "$(docker inspect --format '{{.State.Health.Status}}' zebra)" = healthy ]; do | |
| [ "$SECONDS" -lt "$deadline" ] || { echo "::error::zebra never became healthy"; exit 1; } | |
| sleep 5 | |
| done | |
| # Every RPC probe below carries a PER-CALL timeout on top of its retry | |
| # deadline. The deadline alone only bounds retries: a probe that HANGS | |
| # (the production z_listunspent hang was exactly this class) would never | |
| # return to the deadline check and would burn the 60-minute job timeout | |
| # as an unattributed failure. With per-call timeouts, a hang becomes a | |
| # failed probe within seconds and the step that caught it is named. | |
| - name: Zaino serves lightwalletd gRPC | |
| run: | | |
| set -euxo pipefail | |
| # Plain grep (not -q): -q closes the pipe on match, and under | |
| # pipefail the resulting SIGPIPE would read as failure. | |
| deadline=$((SECONDS + 180)) | |
| until timeout 20 docker run --rm --network z3-stack_default \ | |
| -v "$GITHUB_WORKSPACE/zaino/packages/zaino-proto/lightwallet-protocol/walletrpc:/protos:ro" \ | |
| fullstorydev/grpcurl -plaintext -max-time 15 -import-path /protos -proto service.proto \ | |
| zaino:8137 cash.z.wallet.sdk.rpc.CompactTxStreamer/GetLightdInfo \ | |
| | grep chainName; do | |
| [ "$SECONDS" -lt "$deadline" ] || { echo "::error::zaino gRPC never answered"; exit 1; } | |
| sleep 5 | |
| done | |
| - name: Zallet answers wallet RPC with a node tip | |
| run: | | |
| set -euxo pipefail | |
| deadline=$((SECONDS + 180)) | |
| until timeout 20 docker compose exec -T zallet zallet \ | |
| --datadir /var/lib/zallet --config /etc/zallet/zallet.toml \ | |
| rpc getwalletstatus | grep node_tip; do | |
| [ "$SECONDS" -lt "$deadline" ] || { echo "::error::zallet RPC never reported node_tip"; exit 1; } | |
| sleep 5 | |
| done | |
| - name: z_importaddress is compiled in (transparent-key-import) | |
| run: | | |
| set -euxo pipefail | |
| # Watch-only transparent import is feature-gated at compile time | |
| # (transparent-key-import, pulled in by zcashd-import in the | |
| # Dockerfile). A build without it answers every z_importaddress call | |
| # with "requires the transparent-key-import feature". Invalid hex | |
| # with a parseable account UUID deterministically reaches the | |
| # feature-gated parser instead, which must answer "Invalid hex | |
| # encoding": a positive proof the real implementation is present, | |
| # with no wallet-account state involved. | |
| # (`zallet rpc` parses each param as JSON, hence the quoted strings.) | |
| out=$(timeout 20 docker compose exec -T zallet zallet \ | |
| --datadir /var/lib/zallet --config /etc/zallet/zallet.toml \ | |
| rpc z_importaddress '"00000000-0000-0000-0000-000000000000"' '"zz"' 2>&1) || true | |
| echo "$out" | |
| echo "$out" | grep "Invalid hex encoding" | |
| - name: z_listunspent honors its contract (import, filter, latency) | |
| run: | | |
| set -euxo pipefail | |
| # Pin the wallet-RPC behavior classes that shipped as production | |
| # incidents: a real watch-only import must succeed, filtered and | |
| # unfiltered z_listunspent must answer promptly with well-formed | |
| # results (the v11 dust sweep hung for ~10 minutes on a filtered | |
| # call), and a bad filter address must be a clean error. The wallet | |
| # is fresh and the chain unsynced, so results are structurally | |
| # empty; the contract under test is shape and latency, not content. | |
| # Public test constant (regtest/testnet-derived pubkey; the mainnet | |
| # encoding of its address is derived by the wallet on import). | |
| pubkey="0220f133a0751f6a70ce2dc506da68891b827296a0b13fb7883ceea25f7926f5d5" | |
| zallet_rpc() { | |
| timeout 20 docker compose exec -T zallet zallet \ | |
| --datadir /var/lib/zallet --config /etc/zallet/zallet.toml rpc "$@" | |
| } | |
| account="" | |
| deadline=$((SECONDS + 120)) | |
| until account=$(zallet_rpc z_getnewaccount '"smoke"' | python3 -c 'import json,sys; print(json.load(sys.stdin)["account_uuid"])'); do | |
| [ "$SECONDS" -lt "$deadline" ] || { echo "::error::z_getnewaccount never succeeded"; exit 1; } | |
| sleep 5 | |
| done | |
| imported=$(zallet_rpc z_importaddress "\"$account\"" "\"$pubkey\"" false) | |
| echo "$imported" | |
| address=$(echo "$imported" | python3 -c 'import json,sys; print(json.load(sys.stdin)["address"])') | |
| # Unfiltered and filtered listings: valid JSON arrays, promptly. | |
| zallet_rpc z_listunspent | python3 -c 'import json,sys; assert isinstance(json.load(sys.stdin), list)' | |
| zallet_rpc z_listunspent 1 9999999 true "[\"$address\"]" \ | |
| | python3 -c 'import json,sys; assert json.load(sys.stdin) == []' | |
| # A malformed filter address must be a clean parameter error. | |
| out=$(zallet_rpc z_listunspent 1 9999999 true '["not-an-address"]' 2>&1) || true | |
| echo "$out" | |
| echo "$out" | grep "Not a valid Zcash address" | |
| - name: No crash-loops, no accidental exposure | |
| run: | | |
| set -euxo pipefail | |
| [ "$(docker inspect --format '{{.RestartCount}}' zaino)" = "0" ] | |
| [ "$(docker inspect --format '{{.RestartCount}}' zallet)" = "0" ] | |
| # Only zebra publishes anything, and only its P2P port. | |
| docker port zebra | grep 8233 | |
| [ -z "$(docker port zaino)" ] | |
| [ -z "$(docker port zallet)" ] | |
| - name: Dump state on failure | |
| if: failure() | |
| run: | | |
| docker compose ps || true | |
| # Full logs per container, not a tail: a crash-looping container | |
| # keeps every restart's output in one log, and the interesting | |
| # part (the first crash) is at the top. | |
| for c in zebra zaino zallet; do | |
| echo "::group::$c (RestartCount=$(docker inspect --format '{{.RestartCount}}' "$c" 2>/dev/null || echo '?'))" | |
| docker logs "$c" 2>&1 || true | |
| echo "::endgroup::" | |
| done |