Weekly cross-repo integration test spanning tapyrus-core, tapyrus-signer, and
tapyrus-seeder. It stands up a real 3-signer/threshold-2 federation over a 7-node
tapyrus-core topology using the actual tapyrus-setup ceremony (no faked signatures),
then drives that live network through the operations a real federation performs:
per-node transactions, node lifecycle (stop/restart/resync), a genuine chain reorg,
aggpubkey rotation, and a max-block-size change. See
doc/weekly-integration-test-plan.md for the full
design and rationale, and doc/project-plan.md for what's
implemented vs. still outstanding.
.github/workflows/weekly-integration-test.yml-- the CI workflow (see below).config/repos.py-- default checkout URL/ref for each upstream repo.scripts/-- the ceremony + config-assembly scripts the workflow calls. Seedoc/scripts.mdfor what each one does.docker/docker-compose.yml-- the 7-core-node + redis + 3-signer + seeder stack.secrets/,runtime/,workdir/-- generated at run time (gitignored), never committed.doc/-- design doc, progress tracker, script reference
The workflow (weekly-integration-test.yml) has two trigger shapes:
schedule(Sunday 03:00 UTC / Sunday 12:00 noon JST) andworkflow_dispatch(on demand) run the full-scale scenario -- the defaults in the variable table below.pull_requestandpushtomain(path-filtered toscripts/**,docker/**,config/**,.github/workflows/**) run the same job at a smaller "smoke" scale, so a change to this repo's own scripts/compose/workflow is validated before the following Sunday's run rather than after. Seedoc/work-done.mdfor why.
A single integration-test job runs these steps in order:
- Checkout this repo, then checkout
tapyrus-core+tapyrus-signer+tapyrus-seeder(scripts/checkout_repos.py) -- each repo's ref is independently configurable, see below. - Build Docker images for
tapyrus-core,tapyrus-signer, andtapyrus-seederfrom the checkouts. - Offline ceremony for signer-set-a: build the
tapyrus-setupbinary, then runscripts/generate_dev_secrets.pyto produce the aggregated public key (no containers involved yet). - Genesis signing: build the unsigned genesis via
tapyrus-genesis, then sign it withscripts/sign_genesis.py. - Render
tapyrus.conf, bring upredis. - Bring up
tapyrus-seederand verify it (scripts/verify_seeder.py) -- also brings up the 7 core-* nodes itself, in two bring-up modes in sequence: first addseeder-only (no-connectat all), confirming every node's peer count grows organically from nothing via the seeder's DNS-seed answers alone; then the fixed-connecttopology every later step below depends on, confirming the seeder reports only genuinely-listening nodes (nevercore-7, the one node that doesn't listen in that mode) and that a brand-new 8th node with no topology knowledge of its own genuinely auto-bootstraps through the seeder's DNS answer alone. - Start the node orchestrator (
scripts/start_node_orchestrator.py) -- switches the 7 core-* nodes into chaos-supervised mode. core-1b/2b/3b/core-7 randomly stop/restart/reindex/invalidate themselves for the rest of the job; core-1a/2a/3a (the signers' own RPC targets, threshold 2-of-3) get crash-recovery supervision but never a deliberate chaos action, since disrupting them risks reducing available signing capacity below threshold (seedoc/work-done.md). - Wait for the P2P topology to converge (
scripts/wait_for_topology.py, pollinggetconnectioncountagainst the expected 1/2/1/2/1/2/3 pattern) against the now-finalized fixed topology, then collect a coinbase address from each first-layer node (scripts/collect_coinbase_addresses.py, retries until each node's RPC is actually up). - Bring up signers: assemble each signer's config with
scripts/assemble_signer_configs.py, bring up the 3 signer-set-a containers. - Per-node activity: round-robin TPC + colored-coin traffic across all 7 nodes
with balances confirmed after each block (
scripts/generate_traffic.py), including the 3 first-layer nodes' coinbase income -- observed directly per block (seedoc/work-done.md), not just excluded from the assertion. Runs against the node orchestrator's background chaos from step 7 onward, though chaos is paused for the duration of each block wait -- the overwhelming majority of this step's wall time -- so most real churn lands between workflow steps rather than overlapping traffic generation itself. - Reorg: split the network into two groups, let each build its own real
threshold-signed fork, reconnect, and confirm convergence via
getchaintips(scripts/simulate_reorg.py). - Aggpubkey rotation: run the offline ceremony again for signer-set-b, then the
--xfieldsign/computesig handoff and afederations.tomlwith both entries (scripts/simulate_federation_change.py). - Max block size change: signer-set-b signs off on a new max-block-size via the
same
--xfieldflow, confirmed in effect via RPC at the scheduled height (scripts/simulate_maxblocksize_change.py). - Teardown: collect every container's logs, upload them as a CI artifact, then
docker compose down-- runs unconditionally (if: always()).
The entire scenario above has run successfully end-to-end in real GitHub Actions CI,
not just locally -- see doc/work-done.md's "Full real-CI end-to-end verification".
See doc/project-plan.md's Outstanding work for what's still
untested or unbuilt.
Every variable below is a workflow_dispatch input. workflow_dispatch input
default: fields can't hold an expression, and schedule/pull_request/push runs
have no inputs context to read one from anyway, so no default is set on the inputs
themselves. Two different mechanisms supply one when a field is left blank (a manual
dispatch run that leaves a field blank gets the same value schedule does either way):
core_repo_url/core_repo_ref,signer_repo_url/signer_repo_ref,seeder_repo_url/seeder_repo_reffall back toconfig/repos.py's own default URL/ref for that repo -- the single source of truth for these six, not restated here (see that file for the current values and why). The URL half is for testing a branch that only exists on a fork -- e.g. an upstream PR not yet merged -- by pointing the URL at the fork with the ref set to the branch name.- Every other variable below falls back to a literal in the workflow's
env:block (inputs.x || 'literal'), shown in the table below.pull_request/pushruns use smaller "smoke" values for the reorg variable instead of the full-scale default below (see the workflow'senv:block). This table is the one place those numbers are written down -- each input's owndescription:field just points back here instead of restating them, so the two can't silently drift apart.
Only variables with a wired-in consuming step get an actual workflow_dispatch
input; the rest fall back straight to their env: literal on every trigger,
workflow_dispatch included, with no way to override per-run yet.
| Variable | Default | Controls |
|---|---|---|
core_repo_url |
see config/repos.py |
tapyrus-core git URL to check out from -- a fork, to test a branch that only exists there |
core_repo_ref |
see config/repos.py |
tapyrus-core branch/tag/sha to check out |
signer_repo_url |
see config/repos.py |
tapyrus-signer git URL to check out from -- a fork, to test a branch that only exists there |
signer_repo_ref |
see config/repos.py |
tapyrus-signer branch/tag/sha to check out |
seeder_repo_url |
see config/repos.py |
tapyrus-seeder git URL to check out from -- a fork, to test a branch that only exists there |
seeder_repo_ref |
see config/repos.py |
tapyrus-seeder branch/tag/sha to check out |
tx_round_count |
60 (10 on pull_request/push) |
Round-robin send/settle cycles scripts/generate_traffic.py runs -- each round sends 14 transactions (7 nodes x {TPC send, colored send-or-mint}) then settles over however many blocks it actually takes, so this alone determines the tx/block totals for that step. Also determines the reorg's baseline height -- see below. Sized against the CI timing budget, see doc/work-done.md |
reorg_length_blocks |
30 (5 on pull_request/push) |
Blocks each isolated group builds past the baseline, alone, before reconnecting at the tie (scripts/simulate_reorg.py: group B builds first while group A is stopped entirely, then group A builds its own, genuinely different set while group B is stopped). Group B is then always extended by exactly 2 more blocks to win (not 1 -- core-3a produced group A's original tip itself, so it needs a second block to reclassify that tip as valid-fork instead of valid-headers) -- not configurable, and not probed for -- see doc/scripts.md |
round_duration_seconds |
30 |
tapyrus-signerd round-duration (block interval) -- verified clean, see doc/work-done.md's Lessons learnt (10 is confirmed to hit transient InvalidBlock errors) |
network_id |
1905960821 |
Tapyrus network id (prod mode, see doc/work-done.md), used by every core-* node's rendered tapyrus.conf and the genesis.<id> file tapyrusd looks for |
docker_build_platform |
(empty, runner-native) | Docker --platform for image builds -- local verification only ever used linux/arm64 |
FEDERATION_CHANGE_OFFSET_BLOCKS is always REORG_LENGTH_BLOCKS (computed by the "Derive
FEDERATION_CHANGE_OFFSET_BLOCKS from REORG_LENGTH_BLOCKS" workflow step), and MAX_BLOCK_SIZE_OFFSET_BLOCKS
is always FEDERATION_CHANGE_OFFSET_BLOCKS in turn -- scripts/simulate_federation_change.py
and scripts/simulate_maxblocksize_change.py each schedule their change that many
blocks past whatever height the chain is at when they run, not a fixed literal.
max_block_size_new (2000000) is env-literal only, no per-run workflow_dispatch
override yet.
No input at all, and no env-literal fallback either (not just not-yet-wired --
gone entirely): tx_total_count, tx_tpc_percent, tx_interval_seconds. Earlier
drafts of scripts/generate_traffic.py took independent knobs for total tx count,
the TPC/colored-coin split, and per-send pacing; the script settled on a single
tx_round_count knob instead (see doc/work-done.md's "generate_traffic.py's
round-count-only design"), so these three no longer correspond to anything the script
reads -- not obsolete inputs waiting for a slot, just dead names.
No input at all (not even an env-literal fallback): reorg_baseline_height.
REORG_BASELINE_HEIGHT is always TX_ROUND_COUNT + 2 (computed by the "Derive
REORG_BASELINE_HEIGHT from TX_ROUND_COUNT" workflow step, since env: block
expressions have no arithmetic operators) -- ties the reorg's baseline directly to
whatever scripts/generate_traffic.py actually produces first in the same job,
rather than an independently-configured value that could silently drift out of sync
with it. scripts/simulate_reorg.py treats this as a floor, not a literal target: it
waits until the chain reaches at least that height, then uses whatever height was
actually reached (which is typically well past the floor, since traffic generation
runs first) as the real reference point for both forks' target -- see
doc/work-done.md for why that distinction matters.
Same treatment for federation_change_offset_blocks and max_block_size_offset_blocks:
FEDERATION_CHANGE_OFFSET_BLOCKS is always REORG_LENGTH_BLOCKS (computed by the "Derive
FEDERATION_CHANGE_OFFSET_BLOCKS from REORG_LENGTH_BLOCKS" workflow step), and MAX_BLOCK_SIZE_OFFSET_BLOCKS
is always FEDERATION_CHANGE_OFFSET_BLOCKS in turn -- scripts/simulate_federation_change.py
and scripts/simulate_maxblocksize_change.py each schedule their change this many
blocks past whatever height the chain is at when they run (already well past the
reorg/rotation and another traffic round by then), so there's no independently-meaningful
absolute value to expose for either; tying each to the previous step's own height
variable keeps them in the same ballpark instead of separately-tuned literals that
could silently drift apart.
Not configurable per-run at all (hardcoded): max_block_size_new (2000000, the new
value scripts/simulate_maxblocksize_change.py pushes -- no per-run override yet),
the RPC port (12381; RPC auth is a per-process cookie file, not a configurable
credential -- see doc/work-done.md), the signer count (3) / threshold (2) -- the
7-node topology in docker/docker-compose.yml is wired 1:1 to exactly 3 signers, so
changing the count means redesigning the topology, not just passing a different
number -- and
prng_seed_base (always github.run_id): doc/weekly-integration-test-plan.md
requires the PRNG be seeded deterministically per run so a failure is reproducible,
so this is deliberately never a per-run override, not just an as-yet-unwired one.
Conventions the scripts in scripts/ follow, so a new one stays consistent with the
rest -- see doc/scripts.md for what each script actually does, and
doc/work-done.md for the reasoning behind each of these.
- Uniform logging. Every script imports
scripts/lib/log.py'slogand useslog.step/info/warn/errorfor its own narration -- never a bareprint(). Every line is timestamped (UTC, numeric-only, locale-independent) and leveled, so output reads the same way regardless of which script produced it. This is separate from container log collection (the workflow's "Collect logs" step, which pulls each container's own log viadocker logs) -- that captures what tapyrus-core/ tapyrus-signer/tapyrus-seeder themselves logged;log.pycaptures what this repo's own orchestration did. - Async, not blocking subprocess calls + polling loops. Every script that does
subprocess or network I/O is
asyncio-based (asyncio.create_subprocess_execfor external binaries,asyncio.to_thread-wrappedurllibfor RPC calls -- stdlib has no native async HTTP client). Independent operations run concurrently viaasyncio.gatherrather than looping one at a time: checking out 3 repos, each ceremony step's N signer calls, polling all 7 nodes' topology. The one exception isassemble_signer_configs.py, which is pure local file I/O with nothing to overlap, so it stays a plain synchronous script. - All variables configurable at the CI level. Anything a script needs that a CI
run might reasonably want to vary is a
workflow_dispatchinput with a matchingenv:entry (the table above), not hardcoded inside the script. Scripts read these viaos.environ.get(NAME, local_default), so the same script works unmodified whether it's invoked by the workflow (env var set) or run by hand locally (falls back to a sane default).config/repos.pyfollows the same pattern for the three upstream repos' checkout targets. - Python stdlib only, no third-party dependencies, so far. Nothing in
scripts/has needed anything beyond the standard library (asyncio,urllib,argparse,pathlib, etc.) -- norequirements.txt, no virtualenv/package manager setup for CI to install before running these scripts. Worth reconsidering only if a future script genuinely needs something stdlib can't do reasonably.
doc/project-plan.md-- tracked progress, what's done vs. outstanding.doc/scripts.md-- what each script inscripts/does, its inputs/outputs, and known gotchas.doc/weekly-integration-test-plan.md-- the full scenario design and architecture rationale.doc/work-done.md-- transcript of everything manually verified so far (ceremony, topology, reorg, seeder fixes).