README.md owns the short map of repository automation lanes.
This file owns the detailed operator procedure for .github/workflows/remote-e2e.yml.
The remote lane is intentionally a smoke test, not a load test. Its job is to
prove that a real deployment can answer /health, /keys, /metrics, and safe
Solana then Cosmos signing requests (sequential jobs, one fixture per chain; Cosmos needs Solana).
The same tooling serves two audiences; you do not maintain two different request formats.
scripts/generate_remote_e2e_request.sh(Rust binarygenerate_remote_e2e_request) builds the compact JSON bodies the HTTP API expects and prints base64 suitable for GitHub Actions secrets,act.secrets, or ad‑hoc testing.scripts/run_remote_e2e_job.shis the single entrypoint GitHub Actions and operators should use: it checks required env vars, decodesOPENKMS_SIGN_REQUEST_B64into a fixture file, setsOPENKMS_SIGN_PATH(/sign/solanaor/sign/cosmos), then runsremote_e2e_smoke.sh. Keeps.github/workflows/remote-e2e.ymland local/actruns aligned.scripts/remote_e2e_smoke.shimplements the HTTP checks (/health,/keys,/metrics,POSTsign) onceOPENKMS_SIGN_REQUEST_FILEandOPENKMS_SIGN_PATHare set (the job script sets them for you).scripts/e2e_defaults.showns shared Solana and Cosmos default resolution for E2E wrapper scripts.
Self-contained CI — Any workflow that brings up openkms on the runner (for
example the pattern in broadcast-e2e.md) can use the same
generator to produce Solana/Cosmos fixtures and run remote_e2e_smoke.sh
against the local listener (OPENKMS_BASE_URL pointing at localhost or the
service container). The request bytes are identical to what you would paste into
remote-e2e.yml secrets for staging.
End users and operators — The same commands document how to verify their
deployments: build requests from keys they own, set OPENKMS_BASE_URL and
OPENKMS_SIGNER_TOKEN, run the smoke script from a laptop or internal runner,
or wire the generated base64 into remote-e2e.yml for a recurring staging gate.
mockhsm gives fast, deterministic coverage for routing, decoding, and most
policy behavior, but it does not prove:
- the real
yubihsm-connectoris reachable - the deployed host has the expected file permissions and systemd wiring
- the staging URL, bearer tokens, and network path work outside one machine
Treat the remote smoke lane as a release/staging gate, not as a replacement for the fast repo-check lane.
If you want to validate full chain submission, use
broadcast-e2e.md and
.github/workflows/broadcast-e2e.yml. Keep that separate from this workflow so
signer deployment failures stay distinct from testnet RPC or fee/nonce issues.
In GitHub: Settings → Secrets and variables → Actions → Secrets (repository
scope). Missing values surface as errors from scripts/run_remote_e2e_job.sh
or from the Tailscale action.
remote-e2e.yml reads these secrets:
TAILSCALE_OAUTH_CLIENT_ID/TAILSCALE_OAUTH_CLIENT_SECRET: Tailscale OAuth API client withauth_keysscope and tags matching the workflow (seeTAILSCALE_OAUTH_TAGSbelow). This avoids the deprecatedauthkeyinput ontailscale/github-action. (Tailnet Lock deployments may still require a pre-signed auth key instead — fork the workflow to passauthkeyper the action README.)OPENKMS_BASE_URL: signer HTTP base URL reachable on the tailnet after the Tailscale step (e.g.http://my-pi:8443,http://100.x.y.z:8443). The same secret is used on github.com and undergh actonce the job has joined the tailnet.OPENKMS_SIGNER_TOKEN: signer bearer token for the staging environmentOPENKMS_REMOTE_E2E_SOLANA_REQUEST_B64: base64-encoded JSON forPOST /sign/solanaOPENKMS_REMOTE_E2E_COSMOS_REQUEST_B64: base64-encoded JSON forPOST /sign/cosmos
Optional secrets:
OPENKMS_TAILSCALE_PING_HOST: passed to the Tailscale action’spinginput — a comma-separated list of tailnet machines (MagicDNS short name likemy-pi, FQDN, or100.xaddress) the action **tailscale ping**s aftertailscale upuntil one responds (waits for mesh convergence). Often the same host as inOPENKMS_BASE_URL(hostname only, nohttp://). Unset to skip ping and go straight to smoke.
Optional variables:
-
TAILSCALE_OAUTH_TAGS: comma-separated ACL tags for CI nodes (must match your OAuth client), e.g.tag:ci. If unset, the workflow defaults totag:ci— create that tag on the client and allow it in ACLs to reach your signer. -
OPENKMS_EXPECT_SOLANA_KEY_LABEL: if set, the Solana job asserts this label appears inGET /keys -
OPENKMS_EXPECT_COSMOS_KEY_LABEL: if set, the Cosmos job asserts this label appears inGET /keys
If you previously used OPENKMS_REMOTE_E2E_REQUEST_B64 or OPENKMS_EXPECT_KEY_LABEL,
rename those GitHub items to OPENKMS_REMOTE_E2E_SOLANA_REQUEST_B64 and
OPENKMS_EXPECT_SOLANA_KEY_LABEL (same values).
Sign paths are fixed per job (/sign/solana and /sign/cosmos).
The workflow runs scripts/run_remote_e2e_job.sh (normal run: steps, not a
composite action) so act and GitHub both execute the
same shell as operators. Ensure OPENKMS_REMOTE_E2E_*_REQUEST_B64 exist in your
.secrets when using gh act workflow_dispatch -W .github/workflows/remote-e2e.yml.
Self-contained regression for this script lives in tests/remote_e2e_job_shell.rs
and runs in its own CI job under ci.yml (cargo test --test remote_e2e_job_shell -- --ignored)
so the main cargo test log stays shorter; all Rust jobs share rust-cache
with shared-key: openkms to reuse compiled deps.
The workflow runs tailscale/github-action@v4 after checkout so smoke curl
traffic goes over your tailnet, not from GitHub’s public meta actions
addresses directly to your home IP.
TAILSCALE_OAUTH_*credentials must tag runners so your ACLs allowtag:ci(or whateverTAILSCALE_OAUTH_TAGSis) to reach the signer’sOPENKMS_BASE_URLhost and port.OPENKMS_BASE_URLshould use a host/port the runner can resolve and reach only after Tailscale is connected (MagicDNS name of the Pi,100.x, or subnet router target — whatever your tailnet uses).OPENKMS_TAILSCALE_PING_HOST(optional) should name the same machine or an adjacent tailnet hop so the action waits untiltailscale pingsucceeds beforeremote_e2e_smoke.shruns.
On the signer, you still need openkms (or nginx in front of it) listening on an
address the tailnet peer can reach — set [server].listen to 0.0.0.0:PORT
on a dedicated staging host, or to the Pi’s 100.x / tailscale interface address,
or terminate TLS on nginx and keep openkms on 127.0.0.1 — plus ACLs that allow
the CI node’s tag or user to the signer’s port.
- Tailscale on the Pi — Install and log in; note MagicDNS name and
100.x. - ACLs — Allow your CI OAuth tag (e.g.
tag:ci) totcp:PORTon the Pi (samePORTas inOPENKMS_BASE_URL). - HTTP listen — Either
listen = "0.0.0.0:8443"(example) inconfig.tomlso tailnet peers can connect, or keep127.0.0.1:9443and put nginx (or similar) on443/8443withproxy_passto loopback (seedeploy/nginx-openkms-remote-e2e.conf.example). - systemd
IPAddressAllow=— Peers from the tailnet are100.64.0.0/10(and IPv6 ULA if you use it). The stockdeploy/openkms.serviceallows RFC1918 private ranges but not100.64.0.0/10, so direct binds to0.0.0.0still see dropped connections from CI unless you add a drop-inIPAddressAllow=100.64.0.0/10(or use nginx so the peer is localhost). yubihsm-connector—Requires=/ running;[hsm].connector_urlreachable from theopenkmsprocess.- Bearer token —
/etc/openkms/signer.tokenmatchesOPENKMS_SIGNER_TOKENin GitHub /.secrets. - Request fixtures — Secrets
OPENKMS_REMOTE_E2E_*_REQUEST_B64must match keys and policy on the Pi (generate withscripts/generate_remote_e2e_request.sh). - Optional labels — Repo vars
OPENKMS_EXPECT_*_KEY_LABELmust matchGET /keysif set.
If you remove the Tailscale step (or run smoke manually without tailnet), each
job’s curl calls originate from GitHub’s published Actions IP ranges (the JSON
actions array), not from your LAN. Two pieces of the stock homelab layout block
that path unless you adjust them:
[server].listenin/etc/openkms/config.toml— the checked-inexamples/config.tomluses127.0.0.1, which only accepts connections on the signer host itself. Nothing on the public internet (or on another machine) can reach it.deploy/openkms.service—IPAddressDeny=anyplusIPAddressAllow=for localhost and private-space prefixes. Traffic whose peer is a GitHub runner public address is not in that allowlist, so the service cgroup drops the connection even if you bind0.0.0.0.
Recommended (keeps systemd hardening): put TLS on a reverse proxy
(nginx, Caddy, Traefik, cloud LB) that listens on 443 (or another public
port), and proxy_pass to http://127.0.0.1:9443. Leave openkms on
listen = "127.0.0.1:9443". From openkms’s perspective, every request is from
localhost, which matches the unit’s IPAddressAllow= rules. Set
OPENKMS_BASE_URL in GitHub secrets to https://your-staging-host (see
deploy/nginx-openkms-remote-e2e.conf.example).
Alternative (openkms bound on 0.0.0.0 without a local proxy): extend the
unit’s allowlist with GitHub’s actions CIDRs (they change over time; refresh
when runs start failing with timeouts):
sudo install -d /etc/systemd/system/openkms.service.d
./scripts/gen_github_actions_systemd_dropin.sh \
-o /etc/systemd/system/openkms.service.d/github-actions.conf
sudo systemctl daemon-reload
sudo systemctl restart openkmsStaging-only escape hatch: a drop-in that clears IPAddressDeny= /
IPAddressAllow= exposes the process network namespace without the unit’s IP
sandbox — only use on a dedicated staging host, with other controls (firewall,
TLS, rate limits). See deploy/README.md.
Same path as GitHub: remote-e2e.yml runs tailscale/github-action@v4, then curl uses OPENKMS_BASE_URL. Put
the same TAILSCALE_OAUTH_*, OPENKMS_BASE_URL, and smoke secrets in .secrets
(and TAILSCALE_OAUTH_TAGS / label vars in .vars) as for Actions.
Set up job fails on docker pull: If you see authentication required / incorrect username or password while pulling catthehacker/ubuntu:act-latest, that is Docker registry auth, not this workflow. Try docker pull catthehacker/ubuntu:act-latest in a normal shell: fix docker login (Docker Hub) if you use a paid/registry account, or docker logout and retry anonymous pulls; clear stale entries in ~/.docker/config.json; on Docker Desktop (WSL2), confirm you are logged into the right account or reset credentials in Settings.
Why gh act can still fail: nektos/act sets ACT=true
and runs steps inside Docker. tailscaled usually needs /dev/net/tun and
CAP_NET_ADMIN. Without them the Tailscale step often fails with 503 Service Unavailable: no backend.
Configure --container-options "--cap-add=NET_ADMIN --device /dev/net/tun" on the
act / gh act CLI, in .actrc, or in your VS Code / Cursor GitHub Local Actions
runner extension settings (same flags act would receive). Syntax varies by tool; see act --help.
gh act -W .github/workflows/remote-e2e.yml -j solana --secret-file .secrets --var-file .vars \
--container-options "--cap-add=NET_ADMIN --device /dev/net/tun"If tailscaled still fails (some Docker Desktop / WSL setups), fall back to
--privileged for local debugging only — broader than the line above.
Local act vs node24: Older act/gh act rejects actions whose action.yml uses runs.using: node24 (e.g. tailscale/github-action@v4, Swatinem/rust-cache@v2.9.1). actions/checkout@v4 is still node20, which is one reason broadcast-e2e.yml can look easier under act than workflows that pull node24 actions. Upgrade nektos/act (or the gh extension that bundles it) for local runs. This repo pins Swatinem/rust-cache@v2.9.1 in ci.yml / broadcast-e2e.yml while staying on the v2 line.
See Tailscale’s GitHub Action README.
The stable path for “CI always reaches the Pi the same way” is workflow_dispatch on github.com;
use gh act when you want to exercise the workflow locally and accept Docker/Tailscale tuning.
The smoke step runs curl against OPENKMS_BASE_URL from inside the job
environment. gh act may redact the hostname in logs (***); compare with your
.secrets when debugging.
curl: (6) Could not resolve host — OPENKMS_BASE_URL uses a name that
does not resolve (often …example.com placeholders from docs). Use a real DNS
name or a private name your network resolves. See also the warning in
scripts/remote_e2e_smoke.sh when the URL contains example.*.
curl: (7) Failed to connect … port N — after Tailscale, DNS worked but nothing
accepted TCP (timeout or refused):
- Tailscale did not complete — check the Tailscale action log, OAuth tags vs ACLs,
and
OPENKMS_TAILSCALE_PING_HOSTif set (must be reachable from the CI node). http://hostwithout a port uses port 80. If the signer only speaks HTTPS, usehttps://…(port 443 unless you include:port).- Pi listen address — openkms (or nginx) must listen on an interface the tailnet
peer can reach (
0.0.0.0,tailscale0, etc.), not only127.0.0.1, unless you terminate TLS on the same host andproxy_passto localhost (see staging section above). - Firewall on the Pi or path — ensure the tagged CI node can open the port in
OPENKMS_BASE_URL.
act usage has more on container networking.
act starts a small embedded GitHub cache–compatible server by default (so
actions/cache and rust-cache can reserve/upload entries). A log like
reserveCache failed: connect ECONNREFUSED 10.x.x.x:port usually means the
runner container cannot reach the host URL act published (default bind uses an
outbound host IP that Docker does not route on WSL2 / some Linux setups).
Keep the built-in server and fix routing — fixed port, listen on all interfaces, and an external URL the container can use (same idea as act’s artifact server):
--cache-server-addr 0.0.0.0
--cache-server-port 5390
--cache-server-external-url http://host.docker.internal:5390
Put those lines in .actrc or pass them on the CLI. On Linux without
host.docker.internal, try http://172.17.0.1:5390 or add
--add-host=host.docker.internal:host-gateway via act’s container options (see
your act / gh act version).
Skip caching locally — --no-cache-server: no cache API, no save/restore
warnings; each act run pays a full cargo fetch/build cost unless your own mounts
speed that up.
External cache only — if ACTIONS_CACHE_URL is already set in the
environment, act does not start its embedded server (you would point that at
another cache implementation yourself; most people use the flags above instead).
scripts/remote_e2e_smoke.sh never chooses a chain for you: set
OPENKMS_SIGN_PATH (e.g. /sign/solana or /sign/cosmos) together with
OPENKMS_SIGN_REQUEST_FILE, or set OPENKMS_REMOTE_E2E_CHAIN=both with
OPENKMS_SIGN_PATH_SOLANA, OPENKMS_SIGN_PATH_COSMOS,
OPENKMS_SIGN_REQUEST_FILE_SOLANA, and OPENKMS_SIGN_REQUEST_FILE_COSMOS
to run one health/keys/metrics cycle and then both sign calls. Optional
OPENKMS_EXPECT_SOLANA_KEY_LABEL / OPENKMS_EXPECT_COSMOS_KEY_LABEL apply
only in both mode (each is checked against GET /keys when non-empty).
For single-chain runs, the workflow maps OPENKMS_EXPECT_SOLANA_KEY_LABEL /
OPENKMS_EXPECT_COSMOS_KEY_LABEL into OPENKMS_EXPECT_KEY_LABEL per job;
the script still reads OPENKMS_EXPECT_KEY_LABEL for that check.
The request body must match the current HTTP API shape. Example Solana fixture:
{
"label": "solana-hot-0",
"message_b64": "<base64 VersionedMessage>"
}Example Cosmos fixture:
{
"label": "cosmos-hub-0",
"sign_doc_b64": "<base64 SignDoc>",
"expected_chain_id": "theta-testnet-001"
}Encode each JSON body before storing it as a secret (GNU base64):
base64 -w0 request-solana.json # value for OPENKMS_REMOTE_E2E_SOLANA_REQUEST_B64
base64 -w0 request-cosmos.json # value for OPENKMS_REMOTE_E2E_COSMOS_REQUEST_B64Use scripts/generate_remote_e2e_request.sh (wraps cargo run --bin generate_remote_e2e_request) to build the same JSON shape the server expects,
then print base64 of the compact JSON on stdout (suitable for pasting into
the GitHub secret or act’s .secrets). The shell wrapper accepts -e /
--env-file pointing at broadcast-keys.env or its parent directory (same
artifact as broadcast-e2e.md / generate_broadcast_key_material.sh):
that sources the throwaway keys plus Solana/Cosmos tuning so you are not
re-entering RPC, chain id, fees, lamports, and gas/transfer amounts that already
match the broadcast integration tests. From the repo root the Rust binary
then loads ./.secrets then ./.vars (same KEY=value format as act’s secret and var files),
only setting variables that are not already in the environment—so remote key
labels can stay in .secrets while chain tuning comes from -e. Override paths
with OPENKMS_SECRETS_FILE and OPENKMS_VARS_FILE. If you omit -e,
you can set OPENKMS_BROADCAST_KEYS_FILE to the same path so every run picks
it up without repeating the flag.
If you still have a single OPENKMS_REMOTE_E2E_LABEL line (older layout),
the binary copies it into OPENKMS_REMOTE_E2E_SOLANA_LABEL and
OPENKMS_REMOTE_E2E_COSMOS_LABEL when those are unset—enough for both when
both chains use the same key label string, or as a stopgap until you add
explicit per-chain label variables.
The binary still has no implicit chain defaults (nothing guessed from
RPC alone). Use flags, .vars, or -e broadcast-keys.env (which encodes
the same devnet + chain-registry defaults as the broadcast lane) so
expected_chain_id and fee fields are explicit.
Solana (uses getLatestBlockhash on OPENKMS_SOLANA_RPC_URL; builds a
small self-transfer so the signer pubkey is the sole required signer):
./scripts/generate_remote_e2e_request.sh solana \
--label 'solana-hot-0' \
--seed-b64 "$OPENKMS_SOLANA_SIGNER_SEED_B64" \
--rpc-url 'https://api.devnet.solana.com' \
--expected-chain-id devnet \
--lamports 5000Required env names (if you omit equivalent flags): OPENKMS_REMOTE_E2E_SOLANA_LABEL,
OPENKMS_SOLANA_SIGNER_SEED_B64, OPENKMS_SOLANA_RPC_URL, OPENKMS_SOLANA_CHAIN_ID,
OPENKMS_SOLANA_TRANSFER_LAMPORTS. OPENKMS_SOLANA_CHAIN_ID must match the RPC
(e.g. devnet with https://api.devnet.solana.com); the tool does not infer it.
Cosmos (uses OPENKMS_COSMOS_CHAIN_ID and REST for account number /
sequence; builds a MsgSend to self):
./scripts/generate_remote_e2e_request.sh cosmos \
--label 'cosmos-hub-0' \
--rest-url 'https://YOUR_LCD' \
--scalar-b64 "$OPENKMS_COSMOS_SIGNER_SCALAR_B64" \
--hrp cosmos \
--fee-denom uatom \
--fee-amount 4000 \
--gas-limit 200000 \
--transfer-amount 1 \
--chain-id providerRequired env names (if you omit flags): OPENKMS_REMOTE_E2E_COSMOS_LABEL,
OPENKMS_COSMOS_REST_URL, OPENKMS_COSMOS_SIGNER_SCALAR_B64, OPENKMS_COSMOS_HRP,
OPENKMS_COSMOS_FEE_DENOM, OPENKMS_COSMOS_FEE_AMOUNT, OPENKMS_COSMOS_GAS_LIMIT,
OPENKMS_COSMOS_TRANSFER_AMOUNT, OPENKMS_COSMOS_CHAIN_ID.
Both (env-only; prints two base64 lines on stdout (Solana, then Cosmos). The
binary writes a short stderr line before each (OPENKMS_REMOTE_E2E_SOLANA_REQUEST_B64 /
OPENKMS_REMOTE_E2E_COSMOS_REQUEST_B64) so you can tell them apart when the terminal wraps.
--write-json is not supported—run solana and cosmos separately if you need files):
You only need generate_broadcast_key_material.sh when creating a new
./.tmp/broadcast-keys tree. If that directory already exists (e.g. funded
broadcast wallets) and you must not run -f/--force, skip keygen entirely:
put OPENKMS_REMOTE_E2E_SOLANA_LABEL / OPENKMS_REMOTE_E2E_COSMOS_LABEL
in .secrets (or .vars), then point the helper at the existing env file:
./scripts/generate_remote_e2e_request.sh -e ./.tmp/broadcast-keys bothIf broadcast-keys.env is missing newer tuning lines (OPENKMS_SOLANA_RPC_URL,
OPENKMS_SOLANA_CHAIN_ID, …), regenerate the file from existing keys (no Docker,
wallets unchanged):
./scripts/generate_broadcast_key_material.sh --refresh-env ./.tmp/broadcast-keysThen run ./scripts/generate_remote_e2e_request.sh -e ./.tmp/broadcast-keys both again.
Or set everything by hand (same variable names as broadcast tests):
export OPENKMS_REMOTE_E2E_SOLANA_LABEL=… OPENKMS_SOLANA_SIGNER_SEED_B64=… \
OPENKMS_SOLANA_RPC_URL=… OPENKMS_SOLANA_CHAIN_ID=… OPENKMS_SOLANA_TRANSFER_LAMPORTS=5000 \
OPENKMS_REMOTE_E2E_COSMOS_LABEL=… OPENKMS_COSMOS_REST_URL=… OPENKMS_COSMOS_SIGNER_SCALAR_B64=… \
OPENKMS_COSMOS_HRP=cosmos OPENKMS_COSMOS_FEE_DENOM=uatom OPENKMS_COSMOS_FEE_AMOUNT=4000 \
OPENKMS_COSMOS_GAS_LIMIT=200000 OPENKMS_COSMOS_TRANSFER_AMOUNT=1 OPENKMS_COSMOS_CHAIN_ID=…
./scripts/generate_remote_e2e_request.sh bothUseful flags: --show-json (pretty JSON to stderr), --write-json path (single
subcommands only).
Use a payload that signs for a staging-only key and a devnet/testnet account. The service never broadcasts, so the caller that constructs this request should also own the separate "submit to chain" step if you want a fuller system test.
Recommended (matches remote-e2e.yml): use run_remote_e2e_job.sh so you
decode the same secret shape and hit the same paths as CI:
export OPENKMS_BASE_URL="https://your-signer.example.com"
export OPENKMS_SIGNER_TOKEN="..."
export OPENKMS_SIGN_REQUEST_B64="…" # e.g. value of OPENKMS_REMOTE_E2E_SOLANA_REQUEST_B64
export OPENKMS_EXPECT_KEY_LABEL="solana-hot-0" # optional
chmod +x ./scripts/run_remote_e2e_job.sh ./scripts/remote_e2e_smoke.sh
./scripts/run_remote_e2e_job.sh solana
./scripts/run_remote_e2e_job.sh cosmos # second shell with cosmos B64 + labelAdvanced: call remote_e2e_smoke.sh alone when you already have a JSON file
and want full control (e.g. OPENKMS_REMOTE_E2E_CHAIN=both). Then set
OPENKMS_SIGN_PATH and OPENKMS_SIGN_REQUEST_FILE yourself (no defaults).
Two separate invocations (direct smoke script):
export OPENKMS_BASE_URL="https://signer-staging.example.com"
export OPENKMS_SIGNER_TOKEN="..."
OPENKMS_SIGN_PATH="/sign/solana" \
OPENKMS_EXPECT_KEY_LABEL="solana-hot-0" \
OPENKMS_SIGN_REQUEST_FILE="./request-solana.json" \
./scripts/remote_e2e_smoke.sh
OPENKMS_SIGN_PATH="/sign/cosmos" \
OPENKMS_EXPECT_KEY_LABEL="cosmos-hub-0" \
OPENKMS_SIGN_REQUEST_FILE="./request-cosmos.json" \
./scripts/remote_e2e_smoke.shSingle process, both chains (OPENKMS_REMOTE_E2E_CHAIN=both):
export OPENKMS_BASE_URL="https://signer-staging.example.com"
export OPENKMS_SIGNER_TOKEN="..."
export OPENKMS_REMOTE_E2E_CHAIN=both
export OPENKMS_SIGN_PATH_SOLANA=/sign/solana
export OPENKMS_SIGN_PATH_COSMOS=/sign/cosmos
export OPENKMS_SIGN_REQUEST_FILE_SOLANA=./request-solana.json
export OPENKMS_SIGN_REQUEST_FILE_COSMOS=./request-cosmos.json
# optional: OPENKMS_EXPECT_SOLANA_KEY_LABEL=… OPENKMS_EXPECT_COSMOS_KEY_LABEL=…
./scripts/remote_e2e_smoke.shIf the staging service is only reachable inside a private network, run the workflow from a self-hosted runner in that network or invoke the script from an operator machine with equivalent access.