scripts/sepolia/lib.sh354 lines · 17267 bytes
| 1 | # Shared helpers for the Sepolia operator kit. Sourced by the scripts next to it, never run directly. |
| 2 | # |
| 3 | # Two modes, picked by the FORK variable: |
| 4 | # live (default) Ethereum Sepolia through SEPOLIA_RPC (default publicnode); deployment in deployments/sepolia.json |
| 5 | # FORK=<port> a local anvil fork of Sepolia on 127.0.0.1:<port> (start one with fork.sh); everything the kit |
| 6 | # writes goes to $TMPDIR/upgrade-airlock-fork-<port>/, nothing under the repo changes |
| 7 | # AIRLOCK_STATE overrides the live mode's state directory (logs, PID files, the compiled workflow); the systemd |
| 8 | # units set it to a persistent directory so the operator's shell and the services share one place. Fork mode |
| 9 | # ignores it, so a rehearsal never touches the live services' files. |
| 10 | # |
| 11 | # Keys: KEY_DIR (default $HOME/.config/ethsz-2026) holds one hex private key per file: |
| 12 | # owner.key deploys the stack, proposes, executes (the owner can only propose; it is never a guardian) |
| 13 | # attester.key signs the EIP-712 verdicts the AirlockReceiver accepts (a CRE secret) |
| 14 | # sender.key pays the CRE writeReport transactions |
| 15 | # approver.key the human approver: manual clearance (clear.sh) and the codehash registry |
| 16 | # emergency.key the emergency guardian: a human veto and breakGlass |
| 17 | # They are read only at run time, into shell variables, and handed to forge / cast / the CRE simulator. |
| 18 | # Nothing here prints a key; only the derived addresses are shown. |
| 19 | set -euo pipefail |
| 20 | |
| 21 | KIT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) |
| 22 | ROOT=$(cd "$KIT/../.." && pwd) |
| 23 | export PATH="$ROOT/cre/.tools/bin:$ROOT/cre/.tools/node_modules/.bin:$HOME/.foundry/bin:$HOME/.bun/bin:$PATH" |
| 24 | |
| 25 | KEY_DIR=${KEY_DIR:-$HOME/.config/ethsz-2026} |
| 26 | CHAIN_ID=11155111 |
| 27 | EXPLORER=https://sepolia.etherscan.io |
| 28 | SEPOLIA_BLOCK_SECS=12 |
| 29 | TMP_BASE=${TMPDIR:-/tmp} |
| 30 | TMP_BASE=${TMP_BASE%/} |
| 31 | |
| 32 | die() { echo "error: $*" >&2; exit 1; } |
| 33 | say() { printf '%s\n' "$*"; } |
| 34 | now_s() { date +%s; } |
| 35 | lc() { tr 'A-F' 'a-f' <<<"$1"; } |
| 36 | for _t in cast jq bc; do command -v "$_t" >/dev/null 2>&1 || die "$_t is not on the PATH (cast comes with Foundry: foundryup)"; done |
| 37 | unset _t |
| 38 | |
| 39 | if [ -n "${FORK:-}" ]; then |
| 40 | [[ "$FORK" =~ ^[0-9]{2,5}$ ]] || die "FORK must be a local port number (got '$FORK')" |
| 41 | MODE=fork |
| 42 | RPC=http://127.0.0.1:$FORK |
| 43 | STATE=$TMP_BASE/upgrade-airlock-fork-$FORK |
| 44 | DEPLOY_JSON=${DEPLOY_JSON:-$STATE/deployment.json} |
| 45 | NET="anvil fork :$FORK" |
| 46 | else |
| 47 | MODE=live |
| 48 | RPC=${SEPOLIA_RPC:-https://ethereum-sepolia-rpc.publicnode.com} |
| 49 | STATE=${AIRLOCK_STATE:-$TMP_BASE/upgrade-airlock-sepolia} |
| 50 | DEPLOY_JSON=${DEPLOY_JSON:-$ROOT/deployments/sepolia.json} |
| 51 | NET="Ethereum Sepolia" |
| 52 | fi |
| 53 | mkdir -p "$STATE" |
| 54 | |
| 55 | # ---------------------------------------------------------------- keys (never printed) |
| 56 | |
| 57 | # read_key <owner|attester|sender|approver|emergency>: the 0x-prefixed key on stdout, for capture into a variable only. |
| 58 | read_key() { |
| 59 | local f="$KEY_DIR/$1.key" k |
| 60 | [ -r "$f" ] || die "missing or unreadable key file $1.key in KEY_DIR" |
| 61 | k=$(tr -d ' \t\r\n' <"$f") |
| 62 | [[ "$k" =~ ^(0x)?[0-9a-fA-F]{64}$ ]] || die "$1.key does not hold a 32-byte hex private key" |
| 63 | [[ "$k" == 0x* ]] || k=0x$k |
| 64 | printf '%s' "$k" |
| 65 | } |
| 66 | |
| 67 | # addr_of <role> |
| 68 | addr_of() { |
| 69 | local k |
| 70 | k=$(read_key "$1") |
| 71 | cast wallet address --private-key "$k" |
| 72 | } |
| 73 | |
| 74 | # ---------------------------------------------------------------- chain |
| 75 | |
| 76 | # rpc_retry <command...>: runs a read-only chain command up to RPC_TRIES times (default 3), 2 s apart, so a transient |
| 77 | # RPC error (a rate limit, a reset connection, a load-balanced node without the data) does not stop a stage command. |
| 78 | # Only the last attempt's error is shown. Never wrap anything that sends a transaction (send_as): a retry could send |
| 79 | # it twice. |
| 80 | rpc_retry() { |
| 81 | local n=${RPC_TRIES:-3} i=1 out |
| 82 | while :; do |
| 83 | if [ "$i" -lt "$n" ]; then |
| 84 | if out=$("$@" 2>/dev/null); then printf '%s\n' "$out"; return 0; fi |
| 85 | else |
| 86 | if out=$("$@"); then printf '%s\n' "$out"; return 0; fi |
| 87 | return 1 |
| 88 | fi |
| 89 | i=$((i + 1)) |
| 90 | sleep 2 |
| 91 | done |
| 92 | } |
| 93 | |
| 94 | # The RPC must be Sepolia (chain id 11155111); in FORK mode it must also be an anvil node on loopback, so a FORK run |
| 95 | # can never send anything to a public network. |
| 96 | check_rpc() { |
| 97 | local cid cv |
| 98 | cid=$(rpc_retry cast chain-id --rpc-url "$RPC" 2>/dev/null) || die "no RPC answering at $RPC${FORK:+ (start the fork: FORK=$FORK $KIT/fork.sh start)}" |
| 99 | [ "$cid" = "$CHAIN_ID" ] || die "chain id $cid at $RPC, expected $CHAIN_ID (Sepolia)" |
| 100 | if [ "$MODE" = fork ]; then |
| 101 | cv=$(cast rpc web3_clientVersion --rpc-url "$RPC" 2>/dev/null | tr -d '"') || cv=unknown |
| 102 | [[ "$cv" == anvil* ]] || die "FORK=$FORK: $RPC is not an anvil node ($cv); refusing" |
| 103 | fi |
| 104 | } |
| 105 | |
| 106 | block_now() { rpc_retry cast block-number --rpc-url "$RPC"; } |
| 107 | block_ts() { |
| 108 | local b |
| 109 | b=$(rpc_retry cast rpc eth_getBlockByNumber "$(printf '0x%x' "$1")" false --rpc-url "$RPC") || return 1 |
| 110 | cast to-dec "$(jq -r .timestamp <<<"$b")" |
| 111 | } |
| 112 | |
| 113 | # dep <key>: a field of the deployment JSON (airlockAdmin, receiver, evilMint, ...). |
| 114 | dep() { |
| 115 | [ -f "$DEPLOY_JSON" ] || die "no deployment at $DEPLOY_JSON (run deploy.sh${FORK:+ with FORK=$FORK} first)" |
| 116 | local v |
| 117 | v=$(jq -r --arg k "$1" '.[$k] // empty' "$DEPLOY_JSON") |
| 118 | if [ -z "$v" ]; then |
| 119 | [ "$1" = airlockAdmin ] && die "$DEPLOY_JSON has no airlockAdmin, so it is not a deployment of this stack; deploy it with deploy.sh" |
| 120 | die "deployment JSON has no '$1'" |
| 121 | fi |
| 122 | printf '%s' "$v" |
| 123 | } |
| 124 | |
| 125 | tx_ref() { if [ "$MODE" = live ]; then say "$EXPLORER/tx/$1"; else say "$1"; fi; } |
| 126 | addr_ref() { if [ "$MODE" = live ]; then say "$EXPLORER/address/$1"; else say "$1"; fi; } |
| 127 | |
| 128 | # send_as <role> <cast send args...>: sends with that key and prints the receipt JSON. |
| 129 | send_as() { |
| 130 | local who=$1 k |
| 131 | shift |
| 132 | k=$(read_key "$who") |
| 133 | cast send "$@" --private-key "$k" --rpc-url "$RPC" --json |
| 134 | } |
| 135 | |
| 136 | # ---------------------------------------------------------------- AirlockAdmin |
| 137 | |
| 138 | PROPOSAL_SIG='getProposal(uint256)((uint8,uint8,address,address,uint256,bytes32,bytes32,uint64,uint64,uint256))' |
| 139 | STATUS_NAMES=(None Pending Vetoed Executed) |
| 140 | KIND_NAMES=(upgrade set-guardian set-delay set-paused-impl) |
| 141 | ZERO32=0x0000000000000000000000000000000000000000000000000000000000000000 |
| 142 | IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc |
| 143 | |
| 144 | # Rule ids: read from the workflow (cre/veto/rules.ts) so a new rule shows up here without an edit; this list is |
| 145 | # the fallback. ruleHash = keccak256("GUARD/<ID>"). |
| 146 | RULE_IDS=(R1_TOTAL_SUPPLY R2_OWNER R3_TRANSFER_CONSERVATION R4_TRANSFER_LIVENESS R5_STRICT_CODEHASH R6_SIM_FAILURE |
| 147 | R7_CONFIG_GUARD R8_INIT_DATA R3B_THIRD_PARTY_FUNDS R9_NEW_OPCODES) |
| 148 | if [ -f "$ROOT/cre/veto/rules.ts" ]; then |
| 149 | _ids=$(grep -oE "'R[0-9][0-9A-Z_]*'" "$ROOT/cre/veto/rules.ts" | tr -d "'" | awk '!s[$0]++' || true) |
| 150 | [ -n "$_ids" ] && read -r -a RULE_IDS <<<"$(tr '\n' ' ' <<<"$_ids")" |
| 151 | unset _ids |
| 152 | fi |
| 153 | |
| 154 | # proposal <id>: "kind status target readyBlock vetoCount proposedBlock" (numbers as decimals). Fails (status 1, no |
| 155 | # output) when the RPC does not answer after the retries. |
| 156 | proposal() { |
| 157 | local ga raw |
| 158 | ga=$(dep airlockAdmin) |
| 159 | raw=$(rpc_retry cast call "$ga" "$PROPOSAL_SIG" "$1" --rpc-url "$RPC") || return 1 |
| 160 | # (kind, status, proposer, target, value, codehash, dataHash, proposedBlock, readyBlock, vetoCount) |
| 161 | sed -E 's/^\((.*)\)$/\1/; s/ \[[^]]*\]//g' <<<"$raw" | awk -F', ' '{print $1, $2, $4, $9, $10, $8}' |
| 162 | } |
| 163 | |
| 164 | # clearance <id>: the evidence hash AirlockAdmin recorded for the proposal, or 0x00..00 when it is not cleared. |
| 165 | clearance() { rpc_retry cast call "$(dep airlockAdmin)" 'clearanceOf(uint256)(bytes32)' "$1" --rpc-url "$RPC"; } |
| 166 | |
| 167 | # oracle_judged <id>: true once the clearance oracle (the CRE receiver) has answered the proposal with a Clear or a |
| 168 | # Veto, false while it has not (also after an approver's clearance: the sweeper still judges it, and a CRE veto |
| 169 | # wins), "?" when the deployment has no AirlockAdmin.oracleJudged. |
| 170 | oracle_judged() { cast call "$(dep airlockAdmin)" 'oracleJudged(uint256)(bool)' "$1" --rpc-url "$RPC" 2>/dev/null || say '?'; } |
| 171 | |
| 172 | # state_name <status> <clearance>: Pending (not judged yet) / Cleared (pending, may execute after readyBlock) / |
| 173 | # Vetoed / Executed. |
| 174 | state_name() { |
| 175 | if [ "$1" = 1 ] && [ "$2" != "$ZERO32" ]; then say Cleared; else say "${STATUS_NAMES[$1]}"; fi |
| 176 | } |
| 177 | |
| 178 | # rule_name <ruleHash>: R1_TOTAL_SUPPLY etc. |
| 179 | rule_name() { |
| 180 | local want r |
| 181 | want=$(lc "$1") |
| 182 | [ "$want" = "$ZERO32" ] && { say "-"; return; } |
| 183 | for r in "${RULE_IDS[@]}"; do |
| 184 | [ "$(cast keccak "GUARD/$r")" = "$want" ] && { say "$r"; return; } |
| 185 | done |
| 186 | say "unknown($1)" |
| 187 | } |
| 188 | |
| 189 | # impl_name <address>: the deployment key that has this address (v2, evilMint, receiver, emergencyGuardian, ...), |
| 190 | # or the address. |
| 191 | impl_name() { |
| 192 | local a k |
| 193 | a=$(lc "$1") |
| 194 | k=$(jq -r --arg a "$a" '[to_entries[] | select((.value|type)=="string" and (.value|ascii_downcase)==$a) | .key][0] // empty' "$DEPLOY_JSON") |
| 195 | if [ -n "$k" ]; then say "$k"; else say "$1"; fi |
| 196 | } |
| 197 | |
| 198 | # who_name <address>: receiver (CRE), approver, emergencyGuardian, owner, or the address. |
| 199 | who_name() { |
| 200 | local n |
| 201 | n=$(impl_name "$1") |
| 202 | case "$n" in receiver) say "CRE receiver" ;; *) say "$n" ;; esac |
| 203 | } |
| 204 | |
| 205 | # events_since <fromBlock>: one line per AirlockAdmin judgement event, oldest first: |
| 206 | # "vetoed <id> <ruleHash> <guardian> <tx> <block>" |
| 207 | # "cleared <id> <evidenceHash> <clearer> <tx> <block>" |
| 208 | # "reason <id> <reportHash> <guardian> <tx> <block>" |
| 209 | # "executed <id> - <executor> <tx> <block>" |
| 210 | # One eth_getLogs from the deployment block is refused by most public RPCs (publicnode answers some requests with |
| 211 | # 4444 "pruned history unavailable", drpc's free plan caps the range), so the scan runs in windows of EVENTS_CHUNK |
| 212 | # blocks (default 5000). Each window is asked of every history RPC in turn, for up to RPC_TRIES rounds 2 s apart: |
| 213 | # live, HISTORY_RPC (comma-separated) or else SEPOLIA_RPC and then the Tenderly public gateway, which keeps the full |
| 214 | # history; a fork run reads only its anvil (HISTORY_RPC is ignored there). A window no RPC serves is skipped with a |
| 215 | # warning on stderr (rows in it show '-' for the rule, evidence and verdict tx), and the function never fails, so |
| 216 | # `set -e` callers keep going. |
| 217 | # EVENTS_FROM (a block number) starts the scan later. |
| 218 | HISTORY_FALLBACK=https://sepolia.gateway.tenderly.co |
| 219 | events_since() { |
| 220 | local ga tv tc tr te from to lo hi chunk url got round all='' skipped=0 |
| 221 | local -a urls |
| 222 | ga=$(dep airlockAdmin) |
| 223 | from=${EVENTS_FROM:-$1} |
| 224 | chunk=${EVENTS_CHUNK:-5000} |
| 225 | [[ "$from" =~ ^[0-9]+$ ]] || { say "warning: event scan start '$from' is not a block number; no events read" >&2; return 0; } |
| 226 | [[ "$chunk" =~ ^[1-9][0-9]*$ ]] || chunk=5000 |
| 227 | if [ "$MODE" = fork ]; then |
| 228 | urls=("$RPC") # a fork run talks to the loopback anvil only, whatever HISTORY_RPC says |
| 229 | elif [ -n "${HISTORY_RPC:-}" ]; then |
| 230 | IFS=',' read -r -a urls <<<"$HISTORY_RPC" |
| 231 | else |
| 232 | urls=("$RPC" "$HISTORY_FALLBACK") |
| 233 | fi |
| 234 | [ "${#urls[@]}" -gt 0 ] || urls=("$RPC") |
| 235 | to=$(block_now 2>/dev/null) || { say "warning: no block number from $RPC; no events read" >&2; return 0; } |
| 236 | tv=$(cast keccak 'Vetoed(uint256,address,bytes32,uint256)') |
| 237 | tc=$(cast keccak 'Cleared(uint256,address,bytes32)') |
| 238 | tr=$(cast keccak 'ReasonAttached(uint256,address,bytes32)') |
| 239 | te=$(cast keccak 'Executed(uint256,address)') |
| 240 | for ((lo = from; lo <= to; lo += chunk)); do |
| 241 | hi=$((lo + chunk - 1)) |
| 242 | [ "$hi" -le "$to" ] || hi=$to |
| 243 | got= |
| 244 | for ((round = 1; round <= ${RPC_TRIES:-3}; round++)); do |
| 245 | for url in "${urls[@]}"; do |
| 246 | if got=$(logs_window "$url" "$lo" "$hi" "$ga"); then break 2; fi |
| 247 | got= |
| 248 | done |
| 249 | [ "$round" -lt "${RPC_TRIES:-3}" ] && sleep 2 |
| 250 | done |
| 251 | if [ -n "$got" ]; then all+="$got"$'\n'; else skipped=$((skipped + 1)); say "warning: no RPC served AirlockAdmin events in blocks $lo-$hi" >&2; fi |
| 252 | done |
| 253 | [ "$skipped" = 0 ] || say "warning: $skipped event window(s) skipped: rows there show '-' for the rule, evidence and verdict tx (set HISTORY_RPC to an RPC that keeps the history, or EVENTS_FROM to a recent block)" >&2 |
| 254 | [ -n "$all" ] || return 0 |
| 255 | jq -rs --arg tv "$tv" --arg tc "$tc" --arg tr "$tr" --arg te "$te" ' |
| 256 | def w(i): .data[2 + 64 * i: 66 + 64 * i]; |
| 257 | def addr(x): "0x" + x[24:64]; |
| 258 | def num: ltrimstr("0x") | ascii_downcase | explode |
| 259 | | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end)); |
| 260 | add | .[] | . as $l |
| 261 | | (if .topics[0] == $tv then "vetoed \(.topics[1] | num) 0x\(w(0)) \(addr(.topics[2][2:]))" |
| 262 | elif .topics[0] == $tc then "cleared \(.topics[1] | num) 0x\(w(0)) \(addr(.topics[2][2:]))" |
| 263 | elif .topics[0] == $tr then "reason \(.topics[1] | num) 0x\(w(1)) \(addr(w(0)))" |
| 264 | elif .topics[0] == $te then "executed \(.topics[1] | num) - \(addr(w(0)))" |
| 265 | else empty end) + " \($l.transactionHash) \($l.blockNumber | num)"' <<<"$all" \ |
| 266 | || { say "warning: could not parse the event logs; no events read" >&2; return 0; } |
| 267 | } |
| 268 | |
| 269 | # logs_window <rpc> <from> <to> <address>: one eth_getLogs; the JSON array on stdout, or status 1 (cast prints an |
| 270 | # error object instead of an array when the RPC refuses). |
| 271 | logs_window() { |
| 272 | local out |
| 273 | out=$(cast logs --from-block "$2" --to-block "$3" --address "$4" --rpc-url "$1" --json 2>/dev/null) || return 1 |
| 274 | jq -e 'type == "array"' >/dev/null 2>&1 <<<"$out" || return 1 |
| 275 | printf '%s\n' "$out" |
| 276 | } |
| 277 | |
| 278 | # first_event <kind> <id> <events text>: the first matching line ("kind id value who tx block") or nothing. |
| 279 | first_event() { awk -v k="$1" -v id="$2" '$1==k && $2==id && !f {print; f=1}' <<<"$3"; } |
| 280 | |
| 281 | fmt_secs() { local s=$1; if [ "$s" -ge 60 ]; then printf '%dm%02ds' $((s / 60)) $((s % 60)); else printf '%ds' "$s"; fi; } |
| 282 | short() { local h=$1; if [ "${#h}" -gt 14 ]; then printf '%s..%s' "${h:0:8}" "${h: -4}"; else printf '%s' "$h"; fi; } |
| 283 | |
| 284 | # ---------------------------------------------------------------- CRE simulator |
| 285 | |
| 286 | # The workflow config for this mode: live -> cre/veto/config.sepolia.json (make-config.mjs writes it); |
| 287 | # fork -> generated into $STATE from the fork deployment, every provider pointing at the fork. |
| 288 | workflow_config() { |
| 289 | if [ "$MODE" = fork ]; then |
| 290 | CONFIG=$STATE/config.fork.json |
| 291 | if [ ! -f "$CONFIG" ] || [ "$DEPLOY_JSON" -nt "$CONFIG" ] || [ "$KIT/make-config.mjs" -nt "$CONFIG" ]; then |
| 292 | # bun checks the result against the workflow's own schema (cre/veto/config.ts); node writes it unchecked |
| 293 | "$(command -v bun || echo node)" "$KIT/make-config.mjs" "$DEPLOY_JSON" --fork-rpc "$RPC" --out "$CONFIG" >/dev/null |
| 294 | fi |
| 295 | TARGET=sepolia-fork |
| 296 | export VETO_ANVIL_PORT=$FORK # cre/project.yaml: sepolia-fork -> http://127.0.0.1:${VETO_ANVIL_PORT} |
| 297 | else |
| 298 | CONFIG=${WORKFLOW_CONFIG:-$ROOT/cre/veto/config.sepolia.json} |
| 299 | [ -f "$CONFIG" ] || die "no $(basename "$CONFIG") (run: node scripts/sepolia/make-config.mjs)" |
| 300 | TARGET=sepolia |
| 301 | fi |
| 302 | local want have |
| 303 | want=$(lc "$(dep airlockAdmin)") |
| 304 | have=$(lc "$(jq -r '.airlockAdmin // empty' "$CONFIG")") |
| 305 | [ "$have" = "$want" ] || die "$(basename "$CONFIG") watches '${have:-nothing}', the deployment is $want (re-run make-config.mjs)" |
| 306 | } |
| 307 | |
| 308 | # attester_env: the environment variable the simulator reads the attester secret from (cre/secrets.yaml). |
| 309 | attester_env() { |
| 310 | local n |
| 311 | n=$(awk '/^secretsNames:/ {s=1; next} s && /^[[:space:]]+-[[:space:]]+[A-Z0-9_]+/ {print $2; exit}' "$ROOT/cre/secrets.yaml" 2>/dev/null || true) |
| 312 | say "${n:-VETO_ATTESTER_KEY_ALL}" |
| 313 | } |
| 314 | |
| 315 | # sim_bin: the CRE CLI to run (CRE_SIM, default `cre`). |
| 316 | sim_bin() { |
| 317 | SIM=${CRE_SIM:-cre} |
| 318 | command -v "$SIM" >/dev/null 2>&1 || die "CRE CLI '$SIM' not found (cre/setup.sh installs it; or set CRE_SIM)" |
| 319 | } |
| 320 | |
| 321 | # wasm_build: compiles the workflow once per source version (cre workflow build) and sets WASM_ARGS, so the |
| 322 | # listener and the sweeper run the same binary and a sweep does not recompile every minute. The cache is |
| 323 | # <state dir>/wasm/<hash>.wasm, or $TMPDIR/airlock-wasm-<uid>/ when that path is longer than the CLI's 97-character |
| 324 | # limit for --wasm (the directory must belong to this user). WASM_CACHE=0 skips the cache (the simulator then |
| 325 | # compiles on every run). |
| 326 | wasm_build() { |
| 327 | WASM_ARGS=() |
| 328 | [ "${WASM_CACHE:-1}" = 1 ] || return 0 |
| 329 | local h dir out tmp log |
| 330 | h=$(cd "$ROOT/cre/veto" && cat ./*.ts package.json bun.lock 2>/dev/null | shasum -a 256 | cut -c1-16) |
| 331 | dir=$STATE/wasm |
| 332 | [ $((${#dir} + 22)) -le 97 ] || dir=$TMP_BASE/airlock-wasm-$(id -u) |
| 333 | out=$dir/$h.wasm |
| 334 | if [ ${#out} -gt 97 ]; then |
| 335 | say "note: no workflow cache (the path $out is longer than the CLI accepts); the simulator compiles on each run" >&2 |
| 336 | return 0 |
| 337 | fi |
| 338 | mkdir -p "$dir" && chmod 700 "$dir" |
| 339 | [ -O "$dir" ] || { say "note: $dir is not owned by this user; the simulator compiles on each run" >&2; return 0; } |
| 340 | if [ ! -s "$out" ]; then |
| 341 | log=$dir/build-$h.log |
| 342 | tmp=$dir/building-$$.wasm # the CLI appends .wasm to an output path without it |
| 343 | say "compiling the workflow once for this source version (cre workflow build, about a minute) ..." >&2 |
| 344 | if (cd "$ROOT/cre" && "$SIM" workflow build ./veto -o "$tmp" >"$log" 2>&1) && [ -s "$tmp" ]; then |
| 345 | mv "$tmp" "$out" |
| 346 | find "$dir" -name '*.wasm' ! -name "$h.wasm" ! -name 'building-*' -delete 2>/dev/null || true |
| 347 | else |
| 348 | rm -f "$tmp" "$tmp.wasm" |
| 349 | say "note: workflow build failed (log $log); the simulator compiles it on each run instead" >&2 |
| 350 | return 0 |
| 351 | fi |
| 352 | fi |
| 353 | WASM_ARGS=(--wasm "$out") |
| 354 | } |