diff --git a/.github/workflows/validate-task-status.yml b/.github/workflows/validate-task-status.yml new file mode 100644 index 000000000..473ae0b75 --- /dev/null +++ b/.github/workflows/validate-task-status.yml @@ -0,0 +1,28 @@ +name: Validate task status + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate-task-status: + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Check task statuses + run: python3 -B scripts/task-status.py check + - name: Test task status and scaffolding + run: python3 -B -m unittest scripts.tests.test_task_status -v diff --git a/AGENTS.md b/AGENTS.md index ca5c750f2..66089f28a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,9 @@ This is a repo of onchain operational tasks. Pending one-off EVM operations live Standby EVM task lifecycle: -- Canonical standby files contain no real network config, addresses, validations, signatures, or execution records. Invoke by adding only `config//.env` and a concise signer README with `Status: READY TO SIGN`. Generate invocation artifacts there; keep Forge records task-scoped under `records/`. +- Canonical standby files contain no real network config, addresses, validations, signatures, execution records, or status file. Scaffold an invocation from the repository root with `make new-task STANDBY_OP= TASK_NETWORK=`, adding only `config//.env`, a concise signer README with `Status: READY TO SIGN`, and task-root `status.json`. Generate invocation artifacts there; keep Forge records task-scoped under `records/`. - Run directly from standby. Do not copy the operation into active or modify its canonical Makefile, Solidity, or root `FACILITATOR.md` for an incident. Every standby Make command requires command-line `TASK_NETWORK=`, even for one network; exported values and defaults are forbidden. +- Keep task-root `status.json` current as the invocation progresses, including signature counts and final execution or cancellation. Never put status under `config//`, which task-origin signing covers. Finalize status and the signer README together; run `make check-task-status` from the repository root. Archival snapshots status and removes it from standby. - Re-review canonical code when the target ABI, ownership or responder role, base/contracts pin, or signing workflow changes. Pre-review does not replace incident-specific state and transaction validation. - Archive only when every present network invocation is executed or canceled and all final artifacts are committed. Use `make TASK_NETWORK= archive-task TASK_DATE=` after fetching main. This snapshots all networks and task-scoped records into a dated, self-contained archive and removes only invocation files from standby. - Keep the operational PR unmerged until all intended invocations are executed or canceled and the dated archive, final artifacts, and standby invocation removals are committed. On `main`, canonical standby must remain unchanged and contain no invocation-specific data before and after merge. @@ -12,6 +13,8 @@ Standby EVM task lifecycle: Active EVM task lifecycle: - A task directory represents one logical operation and carries its shared Makefile, scripts, and facilitator guide across network rollouts. Add another `config//` to the existing task instead of creating a duplicate task solely to change networks. +- Scaffold from the repository root with `make new-task TASK_SLUG= TASK_NETWORK= [TASK_DATE=YYYY-MM-DD]`, or add a network with `make new-task TASK_ID= TASK_NETWORK=`. These commands require command-line `TASK_NETWORK` and refuse overwrites. +- Keep task-root `status.json` current as each network progresses, including signature counts and final execution or cancellation. Never put status under `config//`, which task-origin signing covers. Finalize status and the signer README together; run `make check-task-status` from the repository root. - A task with multiple network configurations must require `TASK_NETWORK` on each `make` command line. Do not set a mutable default or rely on an exported environment value; use `make TASK_NETWORK= ` so the selected network is visible at every invocation. - Keep the task-root `FACILITATOR.md` authoritative and network-agnostic, using explicit placeholders such as `TASK_NETWORK=` and paths under `config//`. Per-network facilitator files are discouraged because they duplicate configuration and drift from the shared procedure. Only consider one as a last resort when a network has a materially different procedure that cannot be expressed in the root guide, `.env`, or signer README. If unavoidable, place only the procedural delta in `config//FACILITATOR.md` and link to it from the root guide. - Keep a task under `active/evm/tasks/` while any currently intended network rollout remains pending. Do not run `make archive-task` between network rollouts; archive only after every intended network configuration is executed or canceled and its final artifacts are committed. diff --git a/Makefile b/Makefile index a8840ba2d..288850bed 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,24 @@ archive-task: @python3 $(REPO_ROOT)/scripts/standby-task.py archive --repo "$(REPO_ROOT)" --task "$(STANDBY_TASK_DIR)" --network "$(TASK_NETWORK)" --date "$(TASK_DATE)" endif +ifneq ($(filter new-task,$(MAKECMDGOALS)),) +ifneq ($(origin TASK_NETWORK),command line) +$(error TASK_NETWORK is required on the command line, e.g. make new-task TASK_SLUG= TASK_NETWORK=) +endif +endif + +.PHONY: new-task check-task-status +new-task: export TASK_SLUG := $(TASK_SLUG) +new-task: export TASK_ID := $(TASK_ID) +new-task: export STANDBY_OP := $(STANDBY_OP) +new-task: export TASK_NETWORK := $(TASK_NETWORK) +new-task: export TASK_DATE := $(TASK_DATE) +new-task: + @python3 -B "$(REPO_ROOT)/scripts/new-task.py" --repo "$(REPO_ROOT)" + +check-task-status: + @python3 -B "$(REPO_ROOT)/scripts/task-status.py" check --repo "$(REPO_ROOT)" + ## # Toolchain bootstrap (mise) ## diff --git a/README.md b/README.md index 5d03983c7..943b73508 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ active/evm/ └── / ├── Makefile # task dependencies, validation, approvals, and execution ├── FACILITATOR.md # network-agnostic facilitator procedure + ├── status.json # task progress across network invocations ├── script/ # optional one-off task scripts ├── config/ │ └── / @@ -147,7 +148,7 @@ make deps PROJECT_DIR="$PWD/active/evm" BASE_CONTRACTS_COMMIT= ### Standby operations -Canonical standby tasks are dateless, pre-reviewed operations with no real network configuration or artifacts. Invoke directly from `standby/evm/tasks//` by adding `config//.env` and a concise signer README with `Status: READY TO SIGN`. Do not copy the task into active or edit its canonical Makefile, Solidity, or root `FACILITATOR.md` for an incident. Every Make command requires explicit command-line `TASK_NETWORK=`, not an exported value or default. +Canonical standby tasks are dateless, pre-reviewed operations with no real network configuration or artifacts. Invoke directly from `standby/evm/tasks//` by adding `config//.env`, a concise signer README with `Status: READY TO SIGN`, and a task-root `status.json`. Do not copy the task into active or edit its canonical Makefile, Solidity, or root `FACILITATOR.md` for an incident. Every Make command requires explicit command-line `TASK_NETWORK=`, not an exported value or default. Use the [upgrade-delay guide](standby/evm/tasks/delay-upgrade-activation/FACILITATOR.md) or [verifier hash rotation guide](standby/evm/tasks/update-verifier-hashes/FACILITATOR.md) for exact commands. The verifier operation supports AggregateVerifier 0.3.0 at its reviewed contracts pin and replaces the former common verifier scripts. Standby tasks use the shared `active/evm` Foundry project, but each operation's dependency pin and build settings must be respected. Signer discovery includes an invocation only after JSON validations exist under `config//validations/`. The pinned signer checkout receives the deterministic repo-owned patch in `scripts/patches/signer-standby.patch`; changing its upstream pin or content without reviewing the patch fails closed. @@ -166,7 +167,7 @@ standby/evm/tasks// └── .s.sol ``` -Invocation-only `config/`, `signatures/`, and `records/` directories are added during an incident and removed by archival. Later incidents reuse the canonical standby directory. Historical archives must not be edited or executed. +Invocation-only `config/`, `signatures/`, and `records/` directories and the task-root `status.json` are added during an incident and removed by archival. Later incidents reuse the canonical standby directory. Historical archives must not be edited or executed. ### Legacy tasks @@ -177,6 +178,44 @@ Each legacy task (under a network directory, or `archive/legacy/`) has a directo - **src/** place to store any one-off smart contracts (long-lived contracts should go in [base/contracts](https://github.com/base/contracts)) - **.env** place to store task-specific environment variables (contract addresses are inherited from the network-level `.env`) +## Task status and scaffolding + +Every active task and every standby operation with a network invocation has one task-root `status.json`. Humans and the automation bot read and update the same file. Never put it under `config//`, because task-origin signing covers that directory and later status updates must not change the signed payload. A canonical standby operation without invocations must not have a status file; archival carries invocation status into the dated archive and removes it from standby. + +[`scripts/task-status.schema.json`](scripts/task-status.schema.json) defines version 1. All fields are required and unknown fields are rejected. `schema_version` is `1`, `task` matches the task directory name, `kind` is `active` or `standby`, and `last_updated` is a UTC timestamp in `YYYY-MM-DDTHH:MM:SSZ` form. `networks` contains exactly the directories under the task's `config/` and must not be empty. Each network has: + +- `step`, one of the lifecycle steps below. +- `pr`, the task's pull request URL in this repository, or `null` before one exists. +- `blocksec_ticket`, an HTTPS ticket URL, or `null` if none applies. +- `signatures`, an object keyed by validation file stem, such as `base-signer`, with nonnegative integer `collected` and positive integer `required` counts. Use `{}` until the required signer roles are known. +- `executed_tx`, a 32-byte hex transaction hash only when the step is `executed`; otherwise `null`. + +| Step | Meaning | +| --- | --- | +| `authoring` | The facilitator is writing scripts and configuration. | +| `validation` | The facilitator is generating and checking simulation artifacts. | +| `review` | Reviewers are checking the task and validation files. | +| `ready_to_sign` | Reviewed validation files are ready for signers. | +| `collecting` | Signers are submitting signatures. | +| `ready_to_execute` | Required signatures and approvals are ready for execution. | +| `executed` | Execution succeeded and its transaction hash is recorded. | +| `canceled` | The invocation will not execute. | + +The facilitator or bot updates the network step, links, signature counts, and `last_updated` when progress changes. The initial machine step is `authoring` even though the signer README starts with `Status: READY TO SIGN`; do not ask signers to sign until validations are reviewed. Finalize the signer README as `EXECUTED` or `CANCELED` and update `status.json` in the same change. The checker requires both final states to match and accepts only uppercase `EXECUTED` or `CANCELED`, optionally followed by an HTTP(S) URL, or `[EXECUTED]()` or `[CANCELED]()` with an HTTP(S) URL. Other final-looking formats are rejected. + +Run these commands from the repository root. Provide exactly one selector and a command-line `TASK_NETWORK` with a matching global `config/.env`. The date defaults to today; identifiers use lowercase letters, digits, and single hyphens, with slugs, operations, and networks starting with a letter. + +```sh +make new-task TASK_SLUG= TASK_NETWORK= TASK_DATE=YYYY-MM-DD +make new-task TASK_ID= TASK_NETWORK= +make new-task STANDBY_OP= TASK_NETWORK= +make check-task-status +``` + +The first mode creates `active/evm/tasks/-/` with a shared Makefile skeleton, facilitator guide, tracked `script/` directory, network `.env`, signer README, and initial `status.json`. Implement the operation and fill in the empty contracts pin before using it. The second mode adds only a network config and status entry to an existing active task. The third adds a standby invocation without changing canonical files; follow the operation's facilitator guide to fill in its reviewed pin and required inputs. All modes refuse overwrites and leave generated validations, addresses, signatures, and records to the facilitator. + +To adopt an existing task, run `python3 -B scripts/task-status.py add-network ` once for each existing network, then update its progress and final artifacts. The CLI refuses duplicate entries. `make check-task-status` validates all active tasks and invoked standby operations, including network directories, execution hashes, and finalized README consistency. The [task status workflow](.github/workflows/validate-task-status.yml) runs the checker and scaffolder tests on pull requests and pushes to main. + ## CI script validation The GitHub Actions workflow validates shared active scripts and canonical standby Solidity on every pull request and push to `main`. diff --git a/active/evm/tasks/2026-10-07-denim-upgrade/status.json b/active/evm/tasks/2026-10-07-denim-upgrade/status.json new file mode 100644 index 000000000..7ae1d0508 --- /dev/null +++ b/active/evm/tasks/2026-10-07-denim-upgrade/status.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "task": "2026-10-07-denim-upgrade", + "kind": "active", + "last_updated": "2026-10-17T18:10:50Z", + "networks": { + "zeronet": { + "step": "executed", + "pr": "https://github.com/base/contract-deployments/pull/806", + "blocksec_ticket": null, + "signatures": {}, + "executed_tx": "0x9468085edf0a0d5e3dbeea7ab84f13400a496aafc6c21ca8420e4f136c106034" + } + } +} diff --git a/active/evm/tasks/2026-10-15-upgrade-l2-predeploys/status.json b/active/evm/tasks/2026-10-15-upgrade-l2-predeploys/status.json new file mode 100644 index 000000000..76892da5e --- /dev/null +++ b/active/evm/tasks/2026-10-15-upgrade-l2-predeploys/status.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "task": "2026-10-15-upgrade-l2-predeploys", + "kind": "active", + "last_updated": "2026-10-17T18:10:50Z", + "networks": { + "zeronet": { + "step": "executed", + "pr": "https://github.com/base/contract-deployments/pull/817", + "blocksec_ticket": null, + "signatures": {}, + "executed_tx": "0x50c7bd1ee23ffa011dae50eebe1ea32ebf30a64e92e55214688afcbc6e2368a0" + } + } +} diff --git a/scripts/new-task.py b/scripts/new-task.py new file mode 100644 index 000000000..ba13bcf6a --- /dev/null +++ b/scripts/new-task.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +import argparse +import datetime +import importlib.util +import os +from pathlib import Path +import re +import shutil +import sys + +spec = importlib.util.spec_from_file_location("task_status", Path(__file__).with_name("task-status.py")) +status = importlib.util.module_from_spec(spec) +spec.loader.exec_module(status) + +MAKEFILE = """include ../../../../Makefile +include $(REPO_ROOT)/Multisig.mk + +ifneq ($(origin TASK_NETWORK),command line) +$(error TASK_NETWORK is required on the command line, e.g. make TASK_NETWORK= $(MAKECMDGOALS)) +endif +ifeq ($(strip $(TASK_NETWORK)),) +$(error TASK_NETWORK must not be empty) +endif + +PROJECT_DIR := $(abspath ../..) + +include $(REPO_ROOT)/config/$(TASK_NETWORK).env +include config/$(TASK_NETWORK)/.env + +SIGNER_TOOL_PATH := $(REPO_ROOT)/signer-tool +VALIDATIONS_DIR := $(CURDIR)/config/$(TASK_NETWORK)/validations +RPC_URL := $(L1_RPC_URL) +TASK_ORIGIN_DIR := $(CURDIR)/config/$(TASK_NETWORK) +SIGNATURE_DIR := $(CURDIR)/signatures/$(TASK_NETWORK) +TASK_DIR := tasks/@TASK@ + +.DEFAULT_GOAL := help +.PHONY: help +help: +\t@echo 'Implement the task scripts and targets before generating validations. See FACILITATOR.md.' + +# Enable these targets only after setting SCRIPT_NAME, SENDER, and the Safe addresses for this operation. +# gen-validation: deps-signer-tool +#\t$(call GEN_VALIDATION,$(SCRIPT_NAME),$(SAFE),$(SENDER),base-signer.json,) +# approve: +#\t$(call MULTISIG_APPROVE,$(SAFE),$(SIGNATURES)) +# execute: +#\t$(call MULTISIG_EXECUTE,$(SIGNATURES)) +""" + +FACILITATOR = """# Task facilitator guide + +Run commands from this task directory. Replace `` with the rollout network and pass `TASK_NETWORK=` on every Make command line. Exported values and defaults are not accepted. + +## Author and review + +Implement the task-specific scripts and enable the validation, approval, and execution targets in the Makefile before using them. Store one-off scripts in `script/`. Review `config//.env`, fill in `BASE_CONTRACTS_COMMIT` and the operation's inputs, and replace the description in `config//README.md`. Keep this guide network-agnostic. + +```sh +make TASK_NETWORK= deps +``` + +Select a reviewed contracts commit before running `deps`; `forge-deps` rejects an empty pin. Review the scripts, configuration, ownership, and expected onchain changes. Keep the task-root `status.json` current as authoring, validation, and review progress. + +## Generate validation files + +After implementing the targets, generate and inspect `config//validations/base-signer.json` and any other required signer validations. Name validation files after signer roles, such as `security-council-signer.json`. Check the full simulated state diff and replace generated summary placeholders. Collect task-origin signatures only for mainnet scripts that go through the ProxyAdmin owner. Task-origin signing covers `config//`, never the root `status.json`. + +```sh +make TASK_NETWORK= gen-validation +``` + +Commit the reviewed configuration and validation files before asking signers to sign. Set the network step to `ready_to_sign`, then `collecting`, and update signature counts in `status.json` as signatures arrive. Signers use the network README's Sign section and send signatures to the facilitator. + +## Approve and execute + +Confirm each Safe's live threshold and nonce. Collect signatures in Safe owner-address order. Revalidate changed inputs and recollect signatures if the transaction hash changes. Once all required approvals are ready, set the step to `ready_to_execute`. + +```sh +make TASK_NETWORK= approve SIGNATURES= +make TASK_NETWORK= execute SIGNATURES= +``` + +Check the execution state against the signed validation. Commit all final artifacts, including task-scoped Forge `records/`, network-specific addresses and validations, and signatures. Finalize `config//README.md` with `Status: EXECUTED` and its transaction link, or `Status: CANCELED` with a reason. Update the matching step, execution transaction, and `last_updated` in the root `status.json` in the same change. + +## Archive + +Keep the task active until every intended network is executed or canceled and all final artifacts are committed. Do not archive between network rollouts. From the repository root, run `make check-task-status`, then `make archive-task` and select this task. Historical archives must not be edited or executed. +""" + + +def identifier(value, label): + if not status.IDENTIFIER.fullmatch(value): + raise ValueError(f"invalid {label}: {value}") + return value + + +def iso_date(value): + try: + if datetime.date.fromisoformat(value).isoformat() == value: + return value + except ValueError: + pass + raise ValueError(f"invalid TASK_DATE: {value}") + + +def scaffold(repo, slug, task_id, operation, network, date): + if sum(bool(value) for value in (slug, task_id, operation)) != 1: + raise ValueError("provide exactly one of TASK_SLUG, TASK_ID, or STANDBY_OP") + identifier(network, "TASK_NETWORK") + if not (repo / "config" / f"{network}.env").is_file(): + raise ValueError(f"missing global network configuration: config/{network}.env") + date = iso_date(date or datetime.date.today().isoformat()) + if slug: + identifier(slug, "TASK_SLUG") + task = repo / "active/evm/tasks" / f"{date}-{slug}" + if os.path.lexists(task): + raise ValueError(f"task already exists: {task}") + elif task_id: + match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})-(.+)", task_id) + if match: + iso_date(match.group(1)) + identifier(match.group(2), "TASK_ID") + else: + identifier(task_id, "TASK_ID") + task = repo / "active/evm/tasks" / task_id + if not task.is_dir() or task.is_symlink(): + raise ValueError(f"TASK_ID must name an existing active task: {task_id}") + else: + identifier(operation, "STANDBY_OP") + task = repo / "standby/evm/tasks" / operation + if not task.is_dir() or task.is_symlink(): + raise ValueError(f"STANDBY_OP must name an existing standby operation: {operation}") + config = task / "config" / network + if os.path.lexists(config): + raise ValueError(f"network config already exists: {config}") + data = None if slug else status.updated_status(task, network) + if data is not None and set(data["networks"]) - {network} != status.config_networks(task): + raise ValueError(f"{task / 'status.json'}: adopt existing config networks before adding another") + created = [] + try: + if slug: + task.mkdir(parents=True) + created.append(task) + data = status.updated_status(task, network) + (task / "Makefile").write_text(MAKEFILE.replace("@TASK@", task.name)) + (task / "FACILITATOR.md").write_text(FACILITATOR) + (task / "script").mkdir() + (task / "script/.gitkeep").touch() + config.mkdir(parents=True) + if not slug: + created.append(config) + (config / ".env").write_text("BASE_CONTRACTS_COMMIT=\n") + readme = f"# {task.name}\n\nStatus: READY TO SIGN\n\n## Description\n\n\n" + if operation: + readme += "\nFacilitator instructions: [FACILITATOR.md](../../FACILITATOR.md).\n" + readme += f"\n## Sign\n\nFrom the repository root:\n\n```sh\nmake TASK_NETWORK={network} sign-task\n```\n\nSelect `{task.name}` for {network}, sign, and send the signature to the facilitator.\n" + (config / "README.md").write_text(readme) + status.write_status(task, data) + except Exception: + for path in reversed(created): + shutil.rmtree(path) + raise + for path in sorted(task.rglob("*")) if slug else (config / ".env", config / "README.md", task / "status.json"): + if path.is_file(): + print(f"Created or updated {path.relative_to(repo)}") + print(f"Next: follow {task.relative_to(repo)}/FACILITATOR.md, fill in config/{network}/.env and README.md, and keep status.json current. Run make check-task-status.") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + for flag in ("slug", "task-id", "standby-op", "network", "date"): + variable = {"slug": "TASK_SLUG", "task-id": "TASK_ID", "standby-op": "STANDBY_OP", "network": "TASK_NETWORK", "date": "TASK_DATE"}[flag] + parser.add_argument(f"--{flag}", default=os.environ.get(variable, "")) + args = parser.parse_args() + try: + scaffold(args.repo.resolve(), args.slug, args.task_id, args.standby_op, args.network, args.date) + except (OSError, ValueError) as error: + print(f"new-task: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/standby-task.py b/scripts/standby-task.py index b299470fe..8d9be83e6 100644 --- a/scripts/standby-task.py +++ b/scripts/standby-task.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import datetime +import importlib.util import json import os from pathlib import Path @@ -12,6 +13,11 @@ IDENTIFIER = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z") INVOCATION_DIRS = {"config", "records", "signatures"} +INVOCATION_FILES = {"status.json"} + +spec = importlib.util.spec_from_file_location("task_status", Path(__file__).with_name("task-status.py")) +status = importlib.util.module_from_spec(spec) +spec.loader.exec_module(status) def identifier(value, label): @@ -120,14 +126,15 @@ def archive(repo, task, network, date): if task != repo / "standby/evm/tasks" / task.name: raise ValueError("task must be a canonical standby task") all_files = files(task) - canonical = {name: data for name, data in all_files.items() if name.split("/")[0] not in INVOCATION_DIRS} + invocation_paths = INVOCATION_DIRS | INVOCATION_FILES + canonical = {name: data for name, data in all_files.items() if name.split("/")[0] not in invocation_paths} relative_task = task.relative_to(repo).as_posix() try: names = git(repo, "ls-tree", "-r", "--name-only", "refs/remotes/origin/main", "--", relative_task).decode().splitlines() main = { name[len(relative_task) + 1:]: git(repo, "show", f"refs/remotes/origin/main:{name}") for name in names - if name[len(relative_task) + 1:].split("/")[0] not in INVOCATION_DIRS + if name[len(relative_task) + 1:].split("/")[0] not in invocation_paths } except subprocess.CalledProcessError: raise ValueError("cannot read origin/main; fetch main before archiving") from None @@ -141,12 +148,13 @@ def archive(repo, task, network, date): identifier(config.name, "network identifier") if not config.is_dir(): raise ValueError(f"unexpected config artifact: {config}") - readme = config / "README.md" - statuses = [line.removeprefix("Status:").strip() for line in readme.read_text().splitlines()[:20] if line.startswith("Status:")] if readme.is_file() else [] - finalized = r"(?:EXECUTED|CANCELED)(?:\s+https?://\S+)?|\[(?:EXECUTED|CANCELED)\]\(https?://[^\s)]+\)" - if len(statuses) != 1 or re.fullmatch(finalized, statuses[0]) is None: - raise ValueError(f"network {config.name} is not finalized; mark EXECUTED or CANCELED") networks.append(config.name) + data, errors = status.check_task(task, "standby") + if errors: + raise ValueError("invocation status is not finalized:\n" + "\n".join(errors)) + for selected, entry in data["networks"].items(): + if entry["step"] not in ("executed", "canceled"): + raise ValueError(f"network {selected} is not finalized; set status.json step to executed or canceled") signatures = task / "signatures" if signatures.exists() and any(path.name not in networks for path in signatures.iterdir()): raise ValueError("signatures must belong to a configured network") @@ -181,6 +189,10 @@ def archive(repo, task, network, date): source = task / name if source.exists(): shutil.rmtree(source) + for name in INVOCATION_FILES: + source = task / name + if source.exists(): + source.unlink() if files(task) != canonical: raise ValueError("canonical verification failed after archive") print(f"Archived all finalized networks to {destination.relative_to(repo)}; standby canonical files unchanged") diff --git a/scripts/task-status.py b/scripts/task-status.py new file mode 100644 index 000000000..c7fdc03ba --- /dev/null +++ b/scripts/task-status.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +import argparse +import datetime +import json +from pathlib import Path +import re +import sys + +IDENTIFIER = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z") +SCHEMA = Path(__file__).with_name("task-status.schema.json") +FINAL_STATUS = re.compile(r"(EXECUTED|CANCELED)(?:\s+https?://\S+)?|\[(EXECUTED|CANCELED)\]\(https?://[^\s)]+\)") +FINAL_STATUS_PREFIX = re.compile(r"EXECUTED|CANCELED|\[(?:EXECUTED|CANCELED)\]", re.IGNORECASE) + + +def validate(value, schema, location="$"): + types = { + "object": lambda v: isinstance(v, dict), + "string": lambda v: isinstance(v, str), + "integer": lambda v: type(v) is int, + "null": lambda v: v is None, + } + expected = schema.get("type", []) + if isinstance(expected, str): + expected = [expected] + if expected and not any(types[name](value) for name in expected): + return [f"{location}: expected {' or '.join(expected)}"] + errors = [] + if "const" in schema and value != schema["const"]: + errors.append(f"{location}: expected constant {schema['const']!r}") + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{location}: expected one of {schema['enum']!r}") + if isinstance(value, str) and "pattern" in schema: + pattern = schema["pattern"] + if pattern.endswith("$"): + pattern = pattern[:-1] + r"\Z" + if not re.search(pattern, value): + errors.append(f"{location}: does not match pattern {schema['pattern']!r}") + if type(value) is int and "minimum" in schema and value < schema["minimum"]: + errors.append(f"{location}: must be >= {schema['minimum']}") + if isinstance(value, dict): + if len(value) < schema.get("minProperties", 0): + errors.append(f"{location}: requires at least {schema['minProperties']} properties") + for key in schema.get("required", []): + if key not in value: + errors.append(f"{location}.{key}: missing required property") + properties = schema.get("properties", {}) + additional = schema.get("additionalProperties", True) + for key, item in value.items(): + child = f"{location}.{key}" + if "propertyNames" in schema: + errors.extend(validate(key, schema["propertyNames"], child)) + if key in properties: + errors.extend(validate(item, properties[key], child)) + elif additional is False: + errors.append(f"{child}: unknown field") + elif isinstance(additional, dict): + errors.extend(validate(item, additional, child)) + return errors + + +def read_status(path, task, kind): + try: + data = json.loads(path.read_text()) + except FileNotFoundError: + return None, [f"{path}: missing status.json"] + except (OSError, ValueError) as error: + return None, [f"{path}: {error}"] + errors = validate(data, json.loads(SCHEMA.read_text()), "$") + if isinstance(data, dict): + if data.get("task") != task: + errors.append(f"$.task: must equal directory name {task!r}") + if data.get("kind") != kind: + errors.append(f"$.kind: must be {kind!r}") + return data, [f"{path}: {error}" for error in errors] + + +def config_networks(task): + config = task / "config" + return {path.name for path in config.iterdir() if path.is_dir()} if config.is_dir() else set() + + +def check_task(task, kind): + errors = [] + path = task / "status.json" + networks = config_networks(task) + for misplaced in sorted((task / "config").rglob("status.json")): + errors.append(f"{misplaced}: status.json must be at the task root, outside config/") + if kind == "standby" and not networks: + if path.exists(): + errors.append(f"{path}: standby operation with no invocation must not have status.json") + return None, errors + data, invalid = read_status(path, task.name, kind) + errors.extend(invalid) + if invalid: + return data, errors + if set(data["networks"]) != networks: + errors.append(f"{path}: networks must match config directories {sorted(networks)!r}") + for network, entry in data["networks"].items(): + step = entry["step"] + if (step == "executed") != (entry["executed_tx"] is not None): + errors.append(f"{path}: networks.{network}.executed_tx must be non-null if and only if step is executed") + readme = task / "config" / network / "README.md" + try: + lines = readme.read_text().splitlines()[:20] if readme.is_file() else [] + except OSError as error: + errors.append(f"{readme}: {error}") + continue + statuses = [line.removeprefix("Status:").strip() for line in lines if line.startswith("Status:")] + if any(FINAL_STATUS_PREFIX.match(value) and not FINAL_STATUS.fullmatch(value) for value in statuses): + errors.append(f"{readme}: expected EXECUTED or CANCELED, optionally followed by an HTTP(S) URL, " + "or [EXECUTED]() or [CANCELED]() with an HTTP(S) URL") + continue + finals = [match for value in statuses if (match := FINAL_STATUS.fullmatch(value))] + final = (finals[0].group(1) or finals[0].group(2)).lower() if len(statuses) == len(finals) == 1 else None + if (finals or step in ("executed", "canceled")) and final != step: + errors.append(f"{readme}: finalized Status must match status.json step {step!r}") + return data, errors + + +def check(repo): + errors = [] + for kind in ("active", "standby"): + tasks = repo / kind / "evm/tasks" + for task in sorted(tasks.iterdir()) if tasks.is_dir() else []: + if task.is_dir(): + _, invalid = check_task(task, kind) + errors.extend(invalid) + return errors + + +def task_identity(task): + if not task.is_dir() or task.parent.name != "tasks" or task.parent.parent.name != "evm" or task.parent.parent.parent.name not in ("active", "standby"): + raise ValueError(f"{task}: expected an existing active or standby task directory") + return task.parent.parent.parent.name + + +def updated_status(task, network): + if not IDENTIFIER.fullmatch(network): + raise ValueError(f"invalid network identifier: {network}") + kind = task_identity(task) + path = task / "status.json" + if path.exists(): + data, errors = read_status(path, task.name, kind) + if errors: + raise ValueError("\n".join(errors)) + else: + data = {"schema_version": 1, "task": task.name, "kind": kind, "networks": {}} + if network in data["networks"]: + raise ValueError(f"{path}: network {network} already exists") + data["networks"][network] = { + "step": "authoring", "pr": None, "blocksec_ticket": None, + "signatures": {}, "executed_tx": None, + } + data["last_updated"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + errors = validate(data, json.loads(SCHEMA.read_text()), "$") + if errors: + raise ValueError("\n".join(f"{path}: {error}" for error in errors)) + return data + + +def write_status(task, data): + (task / "status.json").write_text(json.dumps(data, indent=2) + "\n") + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + checker = sub.add_parser("check") + checker.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[1]) + add = sub.add_parser("add-network") + add.add_argument("task_dir", type=Path) + add.add_argument("network") + args = parser.parse_args() + try: + if args.command == "check": + errors = check(args.repo.resolve()) + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + print("Task statuses are valid") + else: + task = args.task_dir.resolve() + write_status(task, updated_status(task, args.network)) + print(f"Updated {task / 'status.json'}") + except (OSError, ValueError) as error: + print(f"task-status: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/task-status.schema.json b/scripts/task-status.schema.json new file mode 100644 index 000000000..52db769d3 --- /dev/null +++ b/scripts/task-status.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "task", "kind", "last_updated", "networks"], + "properties": { + "schema_version": {"type": "integer", "const": 1}, + "task": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}, + "kind": {"type": "string", "enum": ["active", "standby"]}, + "last_updated": {"type": "string", "pattern": "^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$"}, + "networks": { + "type": "object", + "minProperties": 1, + "propertyNames": {"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"}, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["step", "pr", "blocksec_ticket", "signatures", "executed_tx"], + "properties": { + "step": {"type": "string", "enum": ["authoring", "validation", "review", "ready_to_sign", "collecting", "ready_to_execute", "executed", "canceled"]}, + "pr": {"type": ["string", "null"], "pattern": "^https://github\\.com/base/contract-deployments/pull/[1-9][0-9]*$"}, + "blocksec_ticket": {"type": ["string", "null"], "pattern": "^https://[^\\s]+$"}, + "signatures": { + "type": "object", + "propertyNames": {"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"}, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["collected", "required"], + "properties": { + "collected": {"type": "integer", "minimum": 0}, + "required": {"type": "integer", "minimum": 1} + } + } + }, + "executed_tx": {"type": ["string", "null"], "pattern": "^0x[0-9a-fA-F]{64}$"} + } + } + } + } +} diff --git a/scripts/tests/test_standby_lifecycle.py b/scripts/tests/test_standby_lifecycle.py index dd10e6308..448ebba4a 100644 --- a/scripts/tests/test_standby_lifecycle.py +++ b/scripts/tests/test_standby_lifecycle.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path import shutil @@ -7,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[2] OPERATION = "delay-upgrade-activation" -INVOCATION_DIRS = {"config", "records", "signatures"} +INVOCATION_DIRS = {"config", "records", "signatures", "status.json"} def run(*args, cwd, env=None): @@ -23,7 +24,8 @@ def setUp(self): temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) self.repo = Path(temp.name) - for name in ("Makefile", "Multisig.mk", "scripts/Standby.mk", "scripts/standby-task.py", "active/evm/foundry.toml"): + for name in ("Makefile", "Multisig.mk", "scripts/Standby.mk", "scripts/standby-task.py", + "scripts/task-status.py", "scripts/task-status.schema.json", "active/evm/foundry.toml"): target = self.repo / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / name, target) @@ -68,6 +70,17 @@ def finalize(self, network="local", status="EXECUTED"): records = self.task / "records/DelayUpgradeActivation.s.sol/31337" records.mkdir(parents=True, exist_ok=True) (records / "run-final.json").write_text("final record\n") + path = self.task / "status.json" + data = json.loads(path.read_text()) if path.exists() else { + "schema_version": 1, "task": self.task.name, "kind": "standby", + "last_updated": "2026-10-20T00:00:00Z", "networks": {}, + } + executed = "EXECUTED" in status + data["networks"][network] = { + "step": "executed" if executed else "canceled", "pr": None, "blocksec_ticket": None, + "signatures": {}, "executed_tx": "0x" + "ab" * 32 if executed else None, + } + path.write_text(json.dumps(data) + "\n") class StandbyLifecycle(StandbyFixture): @@ -199,6 +212,39 @@ def test_archive_refuses_pending_other_network_without_data_loss(self): self.assertEqual(self.snapshot(), before) self.assertFalse((self.repo / "archive").exists()) + def test_archive_rejects_invalid_or_pending_status_without_changes(self): + self.finalize() + path = self.task / "status.json" + finalized = path.read_text() + cases = [("invalid JSON", "{", "status.json"), ("missing", None, "missing status.json")] + for field, value in (("schema_version", 2), ("kind", "active"), ("task", "wrong"), + ("networks", {"other": json.loads(finalized)["networks"]["local"]})): + data = json.loads(finalized) + data[field] = value + cases.append((field, json.dumps(data), field)) + for step, tx, readme, message in ( + ("authoring", None, "EXECUTED", "finalized Status"), + ("executed", None, "EXECUTED", "executed_tx"), + ("canceled", None, "EXECUTED", "finalized Status"), + ("authoring", None, "READY TO SIGN", "not finalized"), + ): + data = json.loads(finalized) + data["networks"]["local"].update(step=step, executed_tx=tx) + cases.append((readme, json.dumps(data), message)) + for readme, content, message in cases: + with self.subTest(status=readme, content=content): + (self.config / "README.md").write_text(f"Status: {readme}\n") + if content is None: + path.unlink() + else: + path.write_text(content) + before = self.snapshot() + result = self.make("archive-task", "local", "TASK_DATE=2026-10-13") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(message, result.stderr) + self.assertEqual(self.snapshot(), before) + self.assertFalse((self.repo / "archive").exists()) + def test_archive_rejects_dated_operation_identifier_without_data_loss(self): self.finalize() renamed = self.task.with_name("2026-10-13-delay-upgrade-activation") diff --git a/scripts/tests/test_task_status.py b/scripts/tests/test_task_status.py new file mode 100644 index 000000000..6c2149ef8 --- /dev/null +++ b/scripts/tests/test_task_status.py @@ -0,0 +1,314 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from scripts.tests.test_standby_lifecycle import copy_canonical_task + +ROOT = Path(__file__).resolve().parents[2] +TASK = "2026-10-20-example" +TX = "0x" + "ab" * 32 + + +def run(*args, cwd, env=None): + return subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True) + + +def status(task=TASK, kind="active"): + return { + "schema_version": 1, "task": task, "kind": kind, + "last_updated": "2026-10-20T00:00:00Z", + "networks": {"local": { + "step": "authoring", "pr": None, "blocksec_ticket": None, + "signatures": {}, "executed_tx": None, + }}, + } + + +class TaskFixture(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.repo = Path(temp.name) + for name in ("Makefile", "Multisig.mk", "scripts/Standby.mk", "scripts/standby-task.py"): + target = self.repo / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / name, target) + for name in ("task-status.py", "task-status.schema.json", "new-task.py"): + source = ROOT / "scripts" / name + if source.exists(): + shutil.copy2(source, self.repo / "scripts" / name) + (self.repo / "config").mkdir() + for network in ("local", "other"): + (self.repo / "config" / f"{network}.env").write_text("L1_RPC_URL=http://127.0.0.1:1\n") + self.standby = self.repo / "standby/evm/tasks/delay-upgrade-activation" + copy_canonical_task(ROOT / "standby/evm/tasks/delay-upgrade-activation", self.standby) + for args in (("init", "-qb", "main"), ("add", "."), + ("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture")): + result = run("git", *args, cwd=self.repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.task = self.repo / "active/evm/tasks" / TASK + + def cli(self, *args): + return run("python3", "-B", str(self.repo / "scripts/task-status.py"), *args, cwd=self.repo) + + def check(self): + return self.cli("check", "--repo", str(self.repo)) + + def write_status(self, data=None, task=None): + task = task or self.task + task.mkdir(parents=True, exist_ok=True) + (task / "status.json").write_text(json.dumps(data if data is not None else status()) + "\n") + + def config(self, task=None, network="local", readme="READY TO SIGN"): + config = (task or self.task) / "config" / network + config.mkdir(parents=True, exist_ok=True) + (config / "README.md").write_text(f"# Example\n\nStatus: {readme}\n") + + def assert_ok(self, result): + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def assert_problem(self, result, path, message): + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(str(path), result.stdout + result.stderr) + self.assertIn(message, result.stdout + result.stderr) + + +class TaskStatus(TaskFixture): + def test_missing_active_status_lists_every_path(self): + self.config() + second = self.task.with_name("2026-10-21-second") + second.mkdir() + result = self.check() + self.assert_problem(result, self.task / "status.json", "missing") + self.assertIn(str(second / "status.json"), result.stderr) + + def test_schema_and_identity_errors(self): + self.config() + cases = [] + for key, value in (("schema_version", True), ("kind", "standby"), ("task", "wrong"), + ("last_updated", "yesterday"), ("extra", 1)): + data = status() + data[key] = value + cases.append((key, data)) + for key, value in (("step", "unknown"), ("executed_tx", "0x1234"), ("extra", 1), + ("pr", "https://example.invalid/not-a-pull"), ("blocksec_ticket", "http://example.invalid"), + ("signatures", {"base-signer": {"collected": -1, "required": 0}}), + ("signatures", {"base-signer": {"collected": True, "required": 1}}), + ("signatures", {"base-signer": {"collected": 0, "required": 1, "extra": 1}})): + data = status() + data["networks"]["local"][key] = value + cases.append((key, data)) + data = status() + del data["networks"]["local"]["pr"] + cases.append(("pr", data)) + for field, data in cases: + with self.subTest(field=field, data=data): + self.write_status(data) + self.assert_problem(self.check(), self.task / "status.json", field) + + def test_network_keys_must_match_config_directories(self): + self.config() + for networks in ({}, {"other": status()["networks"]["local"]}, + {"local": status()["networks"]["local"], "Bad": status()["networks"]["local"]}): + with self.subTest(networks=networks): + data = status() + data["networks"] = networks + self.write_status(data) + self.assert_problem(self.check(), self.task / "status.json", "networks") + + def test_execution_requires_transaction_and_only_execution_allows_it(self): + self.config() + for step, tx in (("executed", None), ("authoring", TX), ("canceled", TX)): + with self.subTest(step=step): + data = status() + data["networks"]["local"].update(step=step, executed_tx=tx) + self.write_status(data) + self.assert_problem(self.check(), self.task / "status.json", "executed_tx") + + def test_executed_transaction_rejects_trailing_newline(self): + self.config(readme="EXECUTED") + data = status() + data["networks"]["local"].update(step="executed", executed_tx=TX + "\n") + self.write_status(data) + self.assert_problem(self.check(), self.task / "status.json", "executed_tx") + + def test_final_readme_and_step_must_agree_in_both_directions(self): + for final in ("EXECUTED", "CANCELED"): + for form in (final, f"{final} https://example.invalid/result", f"[{final}](https://example.invalid/result)"): + with self.subTest(final=final, form=form): + self.config(readme=form) + self.write_status() + self.assert_problem(self.check(), self.task / "config/local/README.md", "step") + data = status() + data["networks"]["local"].update(step=final.lower(), executed_tx=TX if final == "EXECUTED" else None) + self.write_status(data) + self.assert_ok(self.check()) + self.config(readme="READY TO SIGN") + self.assert_problem(self.check(), self.task / "config/local/README.md", "step") + self.config(readme="EXECUTED\nStatus: CANCELED") + self.assert_problem(self.check(), self.task / "config/local/README.md", "step") + + def test_final_looking_readme_requires_supported_format(self): + self.write_status() + for value in ("EXECUTED (https://example.invalid/tx/123)", + "CANCELED because the rollout was superseded", + "executed", "canceled", + "[EXECUTED](https://example.invalid/result) extra", + "[canceled](https://example.invalid/result)"): + with self.subTest(value=value): + self.config(readme=value) + self.assert_problem(self.check(), self.task / "config/local/README.md", + "expected EXECUTED or CANCELED") + + def test_standby_status_exists_only_during_invocation(self): + self.assert_ok(self.check()) + self.write_status(status(self.standby.name, "standby"), self.standby) + self.assert_problem(self.check(), self.standby / "status.json", "no invocation") + (self.standby / "status.json").unlink() + self.config(self.standby) + self.assert_problem(self.check(), self.standby / "status.json", "missing") + self.write_status(status(self.standby.name, "standby"), self.standby) + self.assert_ok(self.check()) + + def test_status_is_rejected_anywhere_in_signed_config(self): + for task, kind in ((self.task, "active"), (self.standby, "standby")): + self.config(task) + self.write_status(status(task.name, kind), task) + for name in ("status.json", "local/status.json", "local/validations/status.json"): + with self.subTest(kind=kind, path=name): + misplaced = task / "config" / name + misplaced.parent.mkdir(parents=True, exist_ok=True) + misplaced.write_text("{}\n") + self.assert_problem(self.check(), misplaced, "outside config/") + misplaced.unlink() + self.assert_ok(self.check()) + + def test_add_network_preserves_existing_progress_and_refuses_duplicates(self): + self.config() + self.assert_ok(self.cli("add-network", str(self.task), "local")) + self.assert_ok(self.check()) + self.assertTrue((self.task / "status.json").is_file(), "add-network did not create status.json") + data = json.loads((self.task / "status.json").read_text()) + data["networks"]["local"]["step"] = "review" + self.write_status(data) + self.config(network="other") + self.assert_ok(self.cli("add-network", str(self.task), "other")) + self.assert_ok(self.check()) + saved = (self.task / "status.json").read_bytes() + self.assertEqual(json.loads(saved)["networks"]["local"], data["networks"]["local"]) + self.assert_problem(self.cli("add-network", str(self.task), "other"), self.task / "status.json", "already exists") + self.assertEqual((self.task / "status.json").read_bytes(), saved) + + def test_schema_file_drives_validation(self): + self.config() + self.write_status() + schema_path = self.repo / "scripts/task-status.schema.json" + schema = json.loads(schema_path.read_text()) + schema["properties"]["schema_version"]["const"] = 2 + schema_path.write_text(json.dumps(schema)) + self.assert_problem(self.check(), self.task / "status.json", "schema_version") + + +class NewTask(TaskFixture): + def make(self, *inputs, env=None): + return run("make", "new-task", *inputs, cwd=self.repo, env=env) + + def snapshot(self): + return {str(p.relative_to(self.repo)): p.read_bytes() for p in self.repo.rglob("*") if p.is_file() and ".git" not in p.parts} + + def test_all_modes_keep_status_outside_signed_config(self): + self.assert_ok(self.make("TASK_SLUG=example", "TASK_DATE=2026-10-20", "TASK_NETWORK=local")) + self.assert_ok(self.check()) + self.assertEqual({str(p.relative_to(self.task)) for p in self.task.rglob("*") if p.is_file()}, + {"Makefile", "FACILITATOR.md", "script/.gitkeep", "config/local/.env", "config/local/README.md", "status.json"}) + before = self.snapshot() + self.assert_ok(self.make(f"TASK_ID={TASK}", "TASK_NETWORK=other")) + self.assert_ok(self.check()) + for name, data in before.items(): + if name != f"active/evm/tasks/{TASK}/status.json": + self.assertEqual((self.repo / name).read_bytes(), data, name) + canonical = {str(p.relative_to(self.standby)): p.read_bytes() for p in self.standby.rglob("*") if p.is_file()} + self.assert_ok(self.make(f"STANDBY_OP={self.standby.name}", "TASK_NETWORK=local")) + self.assert_ok(self.check()) + for name, data in canonical.items(): + self.assertEqual((self.standby / name).read_bytes(), data, name) + for task in (self.task, self.standby): + self.assertTrue((task / "status.json").is_file()) + self.assertFalse(list((task / "config").rglob("status.json"))) + self.assertNotIn("RECORD_STATE_DIFF", (task / "config/local/.env").read_text()) + self.assertIn("BASE_CONTRACTS_COMMIT=\n", (task / "config/local/.env").read_text()) + self.assertIn("Status: READY TO SIGN", (task / "config/local/README.md").read_text()) + parsed = run("make", "TASK_NETWORK=local", "-n", "forge-deps", cwd=self.task) + self.assert_ok(parsed) + missing = run("make", "-n", "forge-deps", cwd=self.task) + self.assertNotEqual(missing.returncode, 0) + self.assertIn("command line", missing.stderr) + + def test_refusals_do_not_write_files(self): + cases = [ + ([], "exactly one"), (["TASK_SLUG=example", "TASK_ID=" + TASK], "exactly one"), + (["TASK_SLUG=example", "STANDBY_OP=delay-upgrade-activation"], "exactly one"), + (["TASK_ID=2026-10-20-missing"], "existing active task"), + (["STANDBY_OP=missing"], "existing standby operation"), + (["TASK_SLUG=../escape"], "invalid"), (["TASK_SLUG=Bad"], "invalid"), + (["TASK_ID=../escape"], "invalid"), (["STANDBY_OP=../escape"], "invalid"), + (["TASK_SLUG=example", "TASK_NETWORK=unknown"], "global network"), + (["TASK_SLUG=example", "TASK_NETWORK=../escape"], "invalid"), + (["TASK_SLUG=example", "TASK_NETWORK=0local"], "invalid"), + (["TASK_SLUG=example", "TASK_NETWORK=local--bad"], "invalid"), + ] + cases += [(["TASK_SLUG=example", f"TASK_DATE={date}"], "invalid TASK_DATE") for date in ("2026-02-30", "20261020", "../escape")] + for inputs, message in cases: + with self.subTest(inputs=inputs): + before = self.snapshot() + result = self.make("TASK_NETWORK=local", *inputs) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stdout + result.stderr) + self.assertEqual(self.snapshot(), before) + for env in (None, dict(os.environ, TASK_NETWORK="local")): + result = self.make("TASK_SLUG=example", env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn("command line", result.stderr) + + def test_overwrites_leave_existing_files_unchanged(self): + self.assert_ok(self.make("TASK_SLUG=example", "TASK_DATE=2026-10-20", "TASK_NETWORK=local")) + self.assert_ok(self.make(f"STANDBY_OP={self.standby.name}", "TASK_NETWORK=local")) + before = self.snapshot() + for selector in ("TASK_SLUG=example", f"TASK_ID={TASK}", f"STANDBY_OP={self.standby.name}"): + with self.subTest(selector=selector): + result = self.make(selector, "TASK_DATE=2026-10-20", "TASK_NETWORK=local") + self.assertNotEqual(result.returncode, 0) + self.assertIn("already exists", result.stdout + result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_existing_active_identifier_does_not_require_a_date_prefix(self): + task = self.task.with_name("example") + self.config(task) + self.write_status(status("example"), task) + self.assert_ok(self.make("TASK_ID=example", "TASK_NETWORK=other")) + self.assert_ok(self.check()) + + def test_existing_networks_must_be_adopted_before_adding_another(self): + self.config() + before = self.snapshot() + result = self.make(f"TASK_ID={TASK}", "TASK_NETWORK=other") + self.assertNotEqual(result.returncode, 0) + self.assertIn("status.json", result.stdout + result.stderr) + self.assertEqual(self.snapshot(), before) + + def test_existing_invalid_status_does_not_leave_partial_network(self): + self.config() + self.write_status({"unexpected": True}) + before = self.snapshot() + result = self.make(f"TASK_ID={TASK}", "TASK_NETWORK=other") + self.assertNotEqual(result.returncode, 0) + self.assertIn("status.json", result.stdout + result.stderr) + self.assertEqual(self.snapshot(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/standby/evm/tasks/delay-upgrade-activation/FACILITATOR.md b/standby/evm/tasks/delay-upgrade-activation/FACILITATOR.md index 8eafad451..c779ddb5c 100644 --- a/standby/evm/tasks/delay-upgrade-activation/FACILITATOR.md +++ b/standby/evm/tasks/delay-upgrade-activation/FACILITATOR.md @@ -10,7 +10,7 @@ This flow calls the responder directly. It does not go through the ProxyAdmin ow ## Configure and prepare -Create only `config//.env` and `config//README.md`. The global `config/.env` supplies RPC, Ledger account, and `PROTOCOL_VERSIONS_PROXY`. Use an invocation-specific proxy override only for a different deployment. Do not put `RECORD_STATE_DIFF` in the task `.env`; the signer tool injects it. +From the repository root, run `make new-task STANDBY_OP=delay-upgrade-activation TASK_NETWORK=` to create `config//.env`, `config//README.md`, and task-root `status.json`. The global `config/.env` supplies RPC, Ledger account, and `PROTOCOL_VERSIONS_PROXY`. Use an invocation-specific proxy override only for a different deployment. Do not put `RECORD_STATE_DIFF` in the task `.env`; the signer tool injects it. The invocation `.env` must contain these values, with decimal upgrade ID and Unix timestamps: @@ -23,6 +23,8 @@ NEW_TIMESTAMP= The signer README must start with a title and `Status: READY TO SIGN`. Briefly state the network, upgrade ID, old and new UTC times, why the delay is needed, and the intended responder Safe. Do not change canonical files. Keep any network-specific addresses and artifacts in `config//`. +Keep task-root `status.json` current as the invocation progresses, including signature counts and `last_updated`. Never put it under `config//`, which task-origin signing covers. + ```sh make TASK_NETWORK= prepare make TASK_NETWORK= deps @@ -48,7 +50,7 @@ make TASK_NETWORK= verify `execute` runs pre/postchecks and submits through the responder Safe. It rejects a frozen or activated target, a stale expected timestamp, insufficient notice, or crossing the next scheduled activation. Re-simulate and regenerate validation if the schedule, Safe nonce, or role changes; recollect signatures when the transaction hash changes. `verify` checks the new timestamp, full live schedule commitment, and responder against global `INCIDENT_MULTISIG`. Confirm that global value matches the live role during freshness review. Compare the execution state diff with the signed validation to verify unchanged schedule length and other timestamps. -Forge broadcasts belong to task-scoped `records/