chaindiff 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {chaindiff-0.1.0/src/chaindiff.egg-info → chaindiff-0.2.0}/PKG-INFO +78 -8
  2. chaindiff-0.2.0/README.md +191 -0
  3. {chaindiff-0.1.0 → chaindiff-0.2.0}/pyproject.toml +2 -2
  4. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/__init__.py +1 -1
  5. chaindiff-0.2.0/src/chaindiff/catalog.py +321 -0
  6. chaindiff-0.2.0/src/chaindiff/cli.py +365 -0
  7. chaindiff-0.2.0/src/chaindiff/configparse.py +343 -0
  8. chaindiff-0.2.0/src/chaindiff/data/advisories/besu.json +19 -0
  9. chaindiff-0.2.0/src/chaindiff/data/advisories/erigon.json +26 -0
  10. chaindiff-0.2.0/src/chaindiff/data/advisories/geth.json +61 -0
  11. chaindiff-0.2.0/src/chaindiff/data/advisories/lighthouse.json +40 -0
  12. chaindiff-0.2.0/src/chaindiff/data/advisories/nethermind.json +33 -0
  13. chaindiff-0.2.0/src/chaindiff/data/advisories/nimbus.json +19 -0
  14. chaindiff-0.2.0/src/chaindiff/data/advisories/prysm.json +19 -0
  15. chaindiff-0.2.0/src/chaindiff/data/advisories/reth.json +19 -0
  16. chaindiff-0.2.0/src/chaindiff/data/advisories/teku.json +26 -0
  17. chaindiff-0.2.0/src/chaindiff/data/flags/besu.json +19 -0
  18. chaindiff-0.2.0/src/chaindiff/data/flags/erigon.json +82 -0
  19. chaindiff-0.2.0/src/chaindiff/data/flags/geth.json +117 -0
  20. chaindiff-0.2.0/src/chaindiff/data/flags/lighthouse.json +42 -0
  21. chaindiff-0.2.0/src/chaindiff/data/flags/nethermind.json +119 -0
  22. chaindiff-0.2.0/src/chaindiff/data/flags/nimbus.json +13 -0
  23. chaindiff-0.2.0/src/chaindiff/data/flags/prysm.json +33 -0
  24. chaindiff-0.2.0/src/chaindiff/data/flags/reth.json +12 -0
  25. chaindiff-0.2.0/src/chaindiff/data/flags/teku.json +34 -0
  26. chaindiff-0.2.0/src/chaindiff/data/networks/ethereum.json +18 -0
  27. chaindiff-0.2.0/src/chaindiff/detect.py +233 -0
  28. chaindiff-0.2.0/src/chaindiff/models.py +149 -0
  29. chaindiff-0.2.0/src/chaindiff/network.py +133 -0
  30. chaindiff-0.2.0/src/chaindiff/report.py +348 -0
  31. chaindiff-0.2.0/src/chaindiff/scan.py +120 -0
  32. {chaindiff-0.1.0 → chaindiff-0.2.0/src/chaindiff.egg-info}/PKG-INFO +78 -8
  33. chaindiff-0.2.0/src/chaindiff.egg-info/SOURCES.txt +61 -0
  34. chaindiff-0.2.0/tests/test_advisories.py +60 -0
  35. chaindiff-0.2.0/tests/test_detect.py +218 -0
  36. chaindiff-0.2.0/tests/test_flags.py +184 -0
  37. chaindiff-0.2.0/tests/test_network.py +259 -0
  38. chaindiff-0.2.0/tests/test_scan.py +280 -0
  39. chaindiff-0.1.0/README.md +0 -121
  40. chaindiff-0.1.0/src/chaindiff/catalog.py +0 -179
  41. chaindiff-0.1.0/src/chaindiff/cli.py +0 -156
  42. chaindiff-0.1.0/src/chaindiff/models.py +0 -69
  43. chaindiff-0.1.0/src/chaindiff/report.py +0 -166
  44. chaindiff-0.1.0/src/chaindiff.egg-info/SOURCES.txt +0 -33
  45. {chaindiff-0.1.0 → chaindiff-0.2.0}/LICENSE +0 -0
  46. {chaindiff-0.1.0 → chaindiff-0.2.0}/setup.cfg +0 -0
  47. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/__main__.py +0 -0
  48. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/clients.json +0 -0
  49. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/besu.json +0 -0
  50. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/erigon.json +0 -0
  51. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/geth.json +0 -0
  52. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/lighthouse.json +0 -0
  53. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/nethermind.json +0 -0
  54. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/nimbus.json +0 -0
  55. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/prysm.json +0 -0
  56. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/reth.json +0 -0
  57. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/data/releases/teku.json +0 -0
  58. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/evaluate.py +0 -0
  59. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/refresh.py +0 -0
  60. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff/versions.py +0 -0
  61. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff.egg-info/dependency_links.txt +0 -0
  62. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff.egg-info/entry_points.txt +0 -0
  63. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff.egg-info/requires.txt +0 -0
  64. {chaindiff-0.1.0 → chaindiff-0.2.0}/src/chaindiff.egg-info/top_level.txt +0 -0
  65. {chaindiff-0.1.0 → chaindiff-0.2.0}/tests/test_catalog.py +0 -0
  66. {chaindiff-0.1.0 → chaindiff-0.2.0}/tests/test_cli.py +0 -0
  67. {chaindiff-0.1.0 → chaindiff-0.2.0}/tests/test_evaluate.py +0 -0
  68. {chaindiff-0.1.0 → chaindiff-0.2.0}/tests/test_refresh.py +0 -0
  69. {chaindiff-0.1.0 → chaindiff-0.2.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chaindiff
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Decide whether an Ethereum node client upgrade is safe.
5
5
  Author: ehsanhajian
6
6
  License-Expression: MIT
@@ -64,7 +64,7 @@ The shipping catalog is Ethereum mainnet. The tool also covers these networks, o
64
64
  - zkSync Era
65
65
  - Starknet
66
66
 
67
- Not in this cut: Lodestar, Grandine, applying the upgrade, node metrics, peer or disk checks, and RPC benchmarks. Configuration scanning and execution/consensus pairing are tracked as later work. They need sourced rules, not guesses.
67
+ Not in this cut: Lodestar, Grandine, applying the upgrade, node metrics, peer or disk checks, and RPC benchmarks. A general execution and consensus pair report is later work. `check --network ethereum` compares the two clients you run with the next mainnet upgrade. `scan` reads a config file you already have and compares it with sourced flag rules. A setting with no rule is reported as not covered. It is not called safe.
68
68
 
69
69
  Calendar-versioned clients (Besu, Teku, Nimbus) do not treat a new year in the version as a breaking change by itself. Semver clients do: an unreviewed major bump is **not safe**.
70
70
 
@@ -74,22 +74,42 @@ Calendar-versioned clients (Besu, Teku, Nimbus) do not treat a new year in the v
74
74
  pip install chaindiff
75
75
  ```
76
76
 
77
- That package is published from a GitHub release. Until the first release is on PyPI:
78
-
79
- ```bash
80
- pip install git+https://github.com/ehsanhajian/chaindiff.git
81
- ```
77
+ That package is published from a GitHub release.
82
78
 
83
79
  ## Commands
84
80
 
85
81
  ```bash
86
82
  chaindiff versions
83
+ chaindiff detect --client geth --binary /usr/bin/geth
84
+ chaindiff detect --client lighthouse --image sigp/lighthouse:v8.2.3
87
85
  chaindiff check --client geth --from <installed>
88
86
  chaindiff check --client lighthouse --from <installed> --to v8.2.3
87
+ chaindiff check --network ethereum --execution geth --execution-version <installed> --consensus lighthouse --consensus-version <installed>
89
88
  chaindiff plan --client nethermind --from <installed>
89
+ chaindiff scan --client geth --from <installed> --to <target> --config <file>
90
90
  chaindiff refresh
91
91
  ```
92
92
 
93
+ `check`, `plan`, `scan`, and `detect` read the client binary. Use them for a mainnet node or a testnet node. A Sepolia Geth upgrade is the same command as a mainnet one. A release note that names a testnet deadline is included when that release is in the range.
94
+
95
+ `detect` reads the version from a client binary, or from a Docker image tag. It does not pull or start an image, and it does not run `check`. Geth is asked with `version`. The other clients are asked with `--version`. Prysm's binary is `beacon-chain` or `validator`.
96
+
97
+ When the output or the tag is one precise release, `detect` prints that version and the `check` command. A tag such as `1.17` is not treated as `1.17.0`. `latest`, `stable`, `nightly`, and the other channel names are not versions. If the binary fails, the tag is not a release, or more than one version appears, it prints:
98
+
99
+ ```bash
100
+ chaindiff check --client geth --from <installed>
101
+ ```
102
+
103
+ Exit 0 means a version was read. Exit 1 means it was not. Exit 3 means the client is unknown.
104
+
105
+ `check --network ethereum` asks whether the execution client and the consensus client you run are the versions the next mainnet upgrade requires, and whether an upgrade order is stated. The schedule is `src/chaindiff/data/networks/ethereum.json`, taken from the network announcement. A Sepolia date in that announcement is not a mainnet deadline. No required mainnet version is invented from a testnet table.
106
+
107
+ **Already current** means both clients are the announced requirement and the mainnet time is set. **Review required** means a required version or the activation time has not been announced, or the installed version is newer than the announcement. **Not safe** means an installed version is older than an announced requirement. A prerelease is still refused on mainnet.
108
+
109
+ `scan` accepts CLI flags, TOML, JSON, or YAML. The format follows the file extension (`.toml`, `.json`, `.yaml`, `.yml`). A file of command-line flags has no extension requirement. `--format cli|toml|json|yaml` overrides that. `--to` defaults to the latest stable release. The command prints what to change. It does not edit the file.
110
+
111
+ Exit 2 means a setting in the file was removed or renamed. Exit 1 means a default changed, a setting is deprecated, or the catalog does not cover a setting. Exit 0 means every setting is covered and none of those changes apply. A stale catalog cannot return exit 0 for `latest`.
112
+
93
113
  `check` and `plan` use the local catalog. `refresh` is the only command that talks to GitHub. Set `GH_TOKEN` or `GITHUB_TOKEN` if you hit the unauthenticated rate limit.
94
114
 
95
115
  ### Verdicts
@@ -134,6 +154,56 @@ Safe and not-safe both require a review. Put reviews in `src/chaindiff/data/advi
134
154
 
135
155
  `severity` is `breaking`, `deprecated`, `note`, or `none`. `none` means the release was reviewed and there is nothing to do. `breaking` and `deprecated` need an `action`. Every advisory needs an `http` or `https` source. Do not mark a release reviewed without reading it.
136
156
 
157
+ ## Flag rules
158
+
159
+ `scan` uses `src/chaindiff/data/flags/<client>.json`. A missing file means the catalog covers nothing for that client.
160
+
161
+ ```json
162
+ {
163
+ "schema_version": 1,
164
+ "rules": [
165
+ {
166
+ "version": "1.17.4",
167
+ "flag": "txlookuplimit",
168
+ "effect": "removed",
169
+ "action": "Drop the flag.",
170
+ "source": "https://github.com/ethereum/go-ethereum/releases/tag/v1.17.4"
171
+ }
172
+ ]
173
+ }
174
+ ```
175
+
176
+ `effect` is `removed`, `renamed`, `deprecated`, or `default`. `renamed` also needs `replacement`. Every rule needs an action and an `http` or `https` source. `flag` is the key the operator set: a CLI name without the leading dashes, or a dotted path such as `JsonRpc.Enabled`. Dashes and underscores match dots. A rule may list `values`. It then matches only when the file sets the key to one of those values, so a new valid value is left alone.
177
+
178
+ A rule applies when its version is after `--from` and at or before `--to`. Removed, renamed, and deprecated rules are reported when the file sets that key. A `default` rule is reported when the file does not set it. The same key outside that range is covered, and it is not listed as a change.
179
+
180
+ ## Networks
181
+
182
+ `check --network` reads `src/chaindiff/data/networks/<network>.json`:
183
+
184
+ ```json
185
+ {
186
+ "schema_version": 1,
187
+ "id": "ethereum",
188
+ "name": "Ethereum mainnet",
189
+ "upgrade": {
190
+ "name": "Glamsterdam",
191
+ "activation": null,
192
+ "source": "https://blog.ethereum.org/2026/09/17/glamsterdam-testnet-announcement",
193
+ "summary": "What the announcement says about mainnet.",
194
+ "order": null,
195
+ "order_summary": "What the announcement says about upgrade order.",
196
+ "warning": "A date or table the operator must not treat as the mainnet requirement.",
197
+ "required": {
198
+ "execution": {},
199
+ "consensus": {}
200
+ }
201
+ }
202
+ }
203
+ ```
204
+
205
+ `activation` is an ISO time, or `null` when mainnet is not scheduled. `order` is `execution-first`, `consensus-first`, or `null`. `required` maps a client id to a `major.minor.patch` version from the announcement. An empty map means that role has no announced requirement. Do not fill it from a testnet table.
206
+
137
207
  ## Development
138
208
 
139
209
  ```bash
@@ -143,4 +213,4 @@ pip install -e ".[dev]"
143
213
  pytest
144
214
  ```
145
215
 
146
- Tests that need a private catalog can set `CHAINDIFF_DATA` to a directory with the same `clients.json`, `releases/`, and `advisories/` layout.
216
+ Tests that need a private catalog can set `CHAINDIFF_DATA` to a directory with the same `clients.json`, `releases/`, `advisories/`, and `flags/` layout.
@@ -0,0 +1,191 @@
1
+ # ChainDiff
2
+
3
+ Decide whether an Ethereum node client upgrade is safe.
4
+
5
+ ChainDiff compares the client you run with the release you want. It answers **safe** only when every release in between has a sourced review and none of those reviews is breaking. Otherwise the answer is **review required** or **not safe**.
6
+
7
+ It does not restart a node, edit config, or apply an upgrade.
8
+
9
+ ## Scope
10
+
11
+ Supported clients:
12
+
13
+ | Client | Role | Release source |
14
+ | --- | --- | --- |
15
+ | Geth | execution | ethereum/go-ethereum |
16
+ | Nethermind | execution | NethermindEth/nethermind |
17
+ | Erigon | execution | erigontech/erigon |
18
+ | Besu | execution | besu-eth/besu |
19
+ | Reth | execution | paradigmxyz/reth |
20
+ | Lighthouse | consensus | sigp/lighthouse |
21
+ | Prysm | consensus | OffchainLabs/prysm |
22
+ | Teku | consensus | Consensys-Incorporated/teku |
23
+ | Nimbus | consensus | status-im/nimbus-eth2 |
24
+
25
+ Reth and Nimbus are in the catalog because operators run them. Besu and Teku are read from their current GitHub repositories.
26
+
27
+ The shipping catalog is Ethereum mainnet. The tool also covers these networks, one task each. A check has to use the clients that network actually runs, and that chain's upgrade, not only the latest tag of an upstream Ethereum client.
28
+
29
+ - Ethereum mainnet
30
+ - Gnosis
31
+ - BNB Smart Chain
32
+ - Avalanche
33
+ - OP Mainnet
34
+ - Base
35
+ - Arbitrum One
36
+ - Polygon PoS
37
+ - Linea
38
+ - Scroll
39
+ - zkSync Era
40
+ - Starknet
41
+
42
+ Not in this cut: Lodestar, Grandine, applying the upgrade, node metrics, peer or disk checks, and RPC benchmarks. A general execution and consensus pair report is later work. `check --network ethereum` compares the two clients you run with the next mainnet upgrade. `scan` reads a config file you already have and compares it with sourced flag rules. A setting with no rule is reported as not covered. It is not called safe.
43
+
44
+ Calendar-versioned clients (Besu, Teku, Nimbus) do not treat a new year in the version as a breaking change by itself. Semver clients do: an unreviewed major bump is **not safe**.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install chaindiff
50
+ ```
51
+
52
+ That package is published from a GitHub release.
53
+
54
+ ## Commands
55
+
56
+ ```bash
57
+ chaindiff versions
58
+ chaindiff detect --client geth --binary /usr/bin/geth
59
+ chaindiff detect --client lighthouse --image sigp/lighthouse:v8.2.3
60
+ chaindiff check --client geth --from <installed>
61
+ chaindiff check --client lighthouse --from <installed> --to v8.2.3
62
+ chaindiff check --network ethereum --execution geth --execution-version <installed> --consensus lighthouse --consensus-version <installed>
63
+ chaindiff plan --client nethermind --from <installed>
64
+ chaindiff scan --client geth --from <installed> --to <target> --config <file>
65
+ chaindiff refresh
66
+ ```
67
+
68
+ `check`, `plan`, `scan`, and `detect` read the client binary. Use them for a mainnet node or a testnet node. A Sepolia Geth upgrade is the same command as a mainnet one. A release note that names a testnet deadline is included when that release is in the range.
69
+
70
+ `detect` reads the version from a client binary, or from a Docker image tag. It does not pull or start an image, and it does not run `check`. Geth is asked with `version`. The other clients are asked with `--version`. Prysm's binary is `beacon-chain` or `validator`.
71
+
72
+ When the output or the tag is one precise release, `detect` prints that version and the `check` command. A tag such as `1.17` is not treated as `1.17.0`. `latest`, `stable`, `nightly`, and the other channel names are not versions. If the binary fails, the tag is not a release, or more than one version appears, it prints:
73
+
74
+ ```bash
75
+ chaindiff check --client geth --from <installed>
76
+ ```
77
+
78
+ Exit 0 means a version was read. Exit 1 means it was not. Exit 3 means the client is unknown.
79
+
80
+ `check --network ethereum` asks whether the execution client and the consensus client you run are the versions the next mainnet upgrade requires, and whether an upgrade order is stated. The schedule is `src/chaindiff/data/networks/ethereum.json`, taken from the network announcement. A Sepolia date in that announcement is not a mainnet deadline. No required mainnet version is invented from a testnet table.
81
+
82
+ **Already current** means both clients are the announced requirement and the mainnet time is set. **Review required** means a required version or the activation time has not been announced, or the installed version is newer than the announcement. **Not safe** means an installed version is older than an announced requirement. A prerelease is still refused on mainnet.
83
+
84
+ `scan` accepts CLI flags, TOML, JSON, or YAML. The format follows the file extension (`.toml`, `.json`, `.yaml`, `.yml`). A file of command-line flags has no extension requirement. `--format cli|toml|json|yaml` overrides that. `--to` defaults to the latest stable release. The command prints what to change. It does not edit the file.
85
+
86
+ Exit 2 means a setting in the file was removed or renamed. Exit 1 means a default changed, a setting is deprecated, or the catalog does not cover a setting. Exit 0 means every setting is covered and none of those changes apply. A stale catalog cannot return exit 0 for `latest`.
87
+
88
+ `check` and `plan` use the local catalog. `refresh` is the only command that talks to GitHub. Set `GH_TOKEN` or `GITHUB_TOKEN` if you hit the unauthenticated rate limit.
89
+
90
+ ### Verdicts
91
+
92
+ - **ALREADY CURRENT** (exit 0) — you are on the target release.
93
+ - **SAFE** (exit 0) — every release in the range has a sourced review, and none is breaking.
94
+ - **REVIEW REQUIRED** (exit 1) — ChainDiff will not call it safe. Releases are missing a review, something is deprecated, or the catalog is too old to trust "latest".
95
+ - **NOT SAFE** (exit 2) — a reviewed breaking change, or an unreviewed semver major bump.
96
+ - **PRERELEASE** (exit 2) — the target is a prerelease. Don't run it on a mainnet node.
97
+ - **DOWNGRADE** (exit 2) — the target is older than what you run.
98
+
99
+ A stale catalog (older than 5 days) cannot produce **safe** or **already current** for `latest`. Comparing two explicit versions still works; the report warns that the catalog is old.
100
+
101
+ The checklist always includes the operator steps that are worth doing on any upgrade: read the notes, back up, keep the old binary, upgrade one client at a time, and roll back if the node fails. Client-specific steps appear only when an advisory says what to change.
102
+
103
+ ## Release catalog
104
+
105
+ GitHub Actions refreshes the catalog twice a week, Monday and Thursday at 06:15 UTC, and commits it when the run succeeds. Run `chaindiff refresh` yourself any time.
106
+
107
+ Erigon tags from the old date scheme, such as `v2022.10.01`, are left out. They are not comparable with the 2.x and 3.x line, and treating them as newer versions would hide the current release.
108
+
109
+ The stored catalog is tags, dates, titles, and URLs. It does not copy release-note bodies. A release title that mentions a security fix is reported as a warning. That warning does not by itself make the upgrade safe or unsafe.
110
+
111
+ ## Advisories
112
+
113
+ Safe and not-safe both require a review. Put reviews in `src/chaindiff/data/advisories/<client>.json`:
114
+
115
+ ```json
116
+ {
117
+ "schema_version": 1,
118
+ "advisories": [
119
+ {
120
+ "version": "2.0.0",
121
+ "severity": "breaking",
122
+ "summary": "What breaks.",
123
+ "action": "What the operator changes.",
124
+ "source": "https://github.com/org/repo/releases/tag/2.0.0"
125
+ }
126
+ ]
127
+ }
128
+ ```
129
+
130
+ `severity` is `breaking`, `deprecated`, `note`, or `none`. `none` means the release was reviewed and there is nothing to do. `breaking` and `deprecated` need an `action`. Every advisory needs an `http` or `https` source. Do not mark a release reviewed without reading it.
131
+
132
+ ## Flag rules
133
+
134
+ `scan` uses `src/chaindiff/data/flags/<client>.json`. A missing file means the catalog covers nothing for that client.
135
+
136
+ ```json
137
+ {
138
+ "schema_version": 1,
139
+ "rules": [
140
+ {
141
+ "version": "1.17.4",
142
+ "flag": "txlookuplimit",
143
+ "effect": "removed",
144
+ "action": "Drop the flag.",
145
+ "source": "https://github.com/ethereum/go-ethereum/releases/tag/v1.17.4"
146
+ }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ `effect` is `removed`, `renamed`, `deprecated`, or `default`. `renamed` also needs `replacement`. Every rule needs an action and an `http` or `https` source. `flag` is the key the operator set: a CLI name without the leading dashes, or a dotted path such as `JsonRpc.Enabled`. Dashes and underscores match dots. A rule may list `values`. It then matches only when the file sets the key to one of those values, so a new valid value is left alone.
152
+
153
+ A rule applies when its version is after `--from` and at or before `--to`. Removed, renamed, and deprecated rules are reported when the file sets that key. A `default` rule is reported when the file does not set it. The same key outside that range is covered, and it is not listed as a change.
154
+
155
+ ## Networks
156
+
157
+ `check --network` reads `src/chaindiff/data/networks/<network>.json`:
158
+
159
+ ```json
160
+ {
161
+ "schema_version": 1,
162
+ "id": "ethereum",
163
+ "name": "Ethereum mainnet",
164
+ "upgrade": {
165
+ "name": "Glamsterdam",
166
+ "activation": null,
167
+ "source": "https://blog.ethereum.org/2026/09/17/glamsterdam-testnet-announcement",
168
+ "summary": "What the announcement says about mainnet.",
169
+ "order": null,
170
+ "order_summary": "What the announcement says about upgrade order.",
171
+ "warning": "A date or table the operator must not treat as the mainnet requirement.",
172
+ "required": {
173
+ "execution": {},
174
+ "consensus": {}
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ `activation` is an ISO time, or `null` when mainnet is not scheduled. `order` is `execution-first`, `consensus-first`, or `null`. `required` maps a client id to a `major.minor.patch` version from the announcement. An empty map means that role has no announced requirement. Do not fill it from a testnet table.
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ python3 -m venv .venv
186
+ source .venv/bin/activate
187
+ pip install -e ".[dev]"
188
+ pytest
189
+ ```
190
+
191
+ Tests that need a private catalog can set `CHAINDIFF_DATA` to a directory with the same `clients.json`, `releases/`, `advisories/`, and `flags/` layout.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chaindiff"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Decide whether an Ethereum node client upgrade is safe."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -39,7 +39,7 @@ build-backend = "setuptools.build_meta"
39
39
  where = ["src"]
40
40
 
41
41
  [tool.setuptools.package-data]
42
- chaindiff = ["data/*.json", "data/releases/*.json", "data/advisories/*.json"]
42
+ chaindiff = ["data/*.json", "data/releases/*.json", "data/advisories/*.json", "data/flags/*.json", "data/networks/*.json"]
43
43
 
44
44
  [tool.pytest.ini_options]
45
45
  testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """Decide whether an Ethereum node client upgrade is safe."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,321 @@
1
+ """Load and store the client registry, release catalog, and advisories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from chaindiff.configparse import normalize_flag
12
+ from chaindiff.models import FLAG_EFFECTS, SEVERITIES, Advisory, Client, FlagRule, NetworkSchedule, Release
13
+ from chaindiff.versions import Version, parse_version
14
+
15
+ SCHEMA_VERSION = 1
16
+ _NETWORK_ORDERS = ("execution-first", "consensus-first")
17
+ _PRECISE_RELEASE = re.compile(r"v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
18
+
19
+
20
+ def data_dir() -> Path:
21
+ override = os.environ.get("CHAINDIFF_DATA")
22
+ if override:
23
+ return Path(override)
24
+ return Path(__file__).resolve().parent / "data"
25
+
26
+
27
+ def format_time(moment: datetime) -> str:
28
+ return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+
31
+ def parse_time(value: str) -> datetime:
32
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
33
+
34
+
35
+ def _read_json(path: Path) -> dict:
36
+ try:
37
+ payload = json.loads(path.read_text())
38
+ except json.JSONDecodeError as exc:
39
+ raise ValueError(f"{path} is not valid JSON") from exc
40
+ if not isinstance(payload, dict):
41
+ raise ValueError(f"{path} must contain a JSON object")
42
+ if payload.get("schema_version") != SCHEMA_VERSION:
43
+ raise ValueError(f"{path} has an unsupported schema_version")
44
+ return payload
45
+
46
+
47
+ def load_clients() -> list[Client]:
48
+ path = data_dir() / "clients.json"
49
+ payload = _read_json(path)
50
+ clients: list[Client] = []
51
+ seen: set[str] = set()
52
+ for raw in payload.get("clients", []):
53
+ client_id = str(raw.get("id", "")).strip()
54
+ role = raw.get("role")
55
+ versioning = raw.get("versioning")
56
+ github = str(raw.get("github", "")).strip()
57
+ name = str(raw.get("name", "")).strip()
58
+ if not client_id or not name or not github:
59
+ raise ValueError(f"{path} has a client missing id, name, or github")
60
+ if role not in ("execution", "consensus"):
61
+ raise ValueError(f"{client_id} has an unknown role")
62
+ if versioning not in ("semver", "calver"):
63
+ raise ValueError(f"{client_id} has an unknown versioning scheme")
64
+ if client_id in seen:
65
+ raise ValueError(f"{client_id} is listed twice")
66
+ seen.add(client_id)
67
+ clients.append(
68
+ Client(
69
+ id=client_id,
70
+ name=name,
71
+ role=role,
72
+ github=github,
73
+ versioning=versioning,
74
+ )
75
+ )
76
+ if not clients:
77
+ raise ValueError(f"{path} does not list any clients")
78
+ return clients
79
+
80
+
81
+ def client_by_id(clients: list[Client], client_id: str) -> Client | None:
82
+ key = client_id.strip().lower()
83
+ for client in clients:
84
+ if client.id == key:
85
+ return client
86
+ return None
87
+
88
+
89
+ def releases_path(client_id: str) -> Path:
90
+ return data_dir() / "releases" / f"{client_id}.json"
91
+
92
+
93
+ def load_releases(client_id: str) -> tuple[datetime, list[Release]] | None:
94
+ path = releases_path(client_id)
95
+ if not path.exists():
96
+ return None
97
+ payload = _read_json(path)
98
+ fetched_at = parse_time(str(payload["fetched_at"]))
99
+ releases: list[Release] = []
100
+ for raw in payload.get("releases", []):
101
+ tag = str(raw.get("tag", ""))
102
+ version = parse_version(tag)
103
+ if version is None:
104
+ continue
105
+ releases.append(
106
+ Release(
107
+ tag=tag,
108
+ name=str(raw.get("name") or tag),
109
+ published_at=str(raw.get("published_at") or ""),
110
+ prerelease=bool(raw.get("prerelease")),
111
+ url=str(raw.get("url") or ""),
112
+ version=version,
113
+ )
114
+ )
115
+ return fetched_at, releases
116
+
117
+
118
+ def save_releases(
119
+ client: Client,
120
+ releases: list[Release],
121
+ ignored_tag_count: int,
122
+ fetched_at: datetime,
123
+ ) -> None:
124
+ path = releases_path(client.id)
125
+ path.parent.mkdir(parents=True, exist_ok=True)
126
+ ordered = sorted(releases, key=lambda item: (item.version, item.published_at, item.tag), reverse=True)
127
+ payload = {
128
+ "schema_version": SCHEMA_VERSION,
129
+ "client": client.id,
130
+ "github": client.github,
131
+ "fetched_at": format_time(fetched_at),
132
+ "ignored_tag_count": ignored_tag_count,
133
+ "releases": [
134
+ {
135
+ "tag": item.tag,
136
+ "name": item.name,
137
+ "published_at": item.published_at,
138
+ "prerelease": item.prerelease,
139
+ "url": item.url,
140
+ }
141
+ for item in ordered
142
+ ],
143
+ }
144
+ text = json.dumps(payload, indent=2) + "\n"
145
+ temporary = path.with_suffix(".json.tmp")
146
+ temporary.write_text(text)
147
+ temporary.replace(path)
148
+
149
+
150
+ def load_advisories(client_id: str) -> list[Advisory]:
151
+ path = data_dir() / "advisories" / f"{client_id}.json"
152
+ if not path.exists():
153
+ return []
154
+ payload = _read_json(path)
155
+ advisories: list[Advisory] = []
156
+ for index, raw in enumerate(payload.get("advisories", []), start=1):
157
+ version_text = str(raw.get("version", "")).strip()
158
+ version = parse_version(version_text)
159
+ severity = str(raw.get("severity", "")).strip()
160
+ summary = str(raw.get("summary", "")).strip()
161
+ action = str(raw.get("action", "")).strip()
162
+ source = str(raw.get("source", "")).strip()
163
+ where = f"{path} advisory {index} ({version_text or 'missing version'})"
164
+ if version is None:
165
+ raise ValueError(f"{where} has a version ChainDiff cannot parse")
166
+ if severity not in SEVERITIES:
167
+ raise ValueError(f"{where} has an unknown severity")
168
+ if not summary:
169
+ raise ValueError(f"{where} needs a summary")
170
+ if severity in ("breaking", "deprecated") and not action:
171
+ raise ValueError(f"{where} needs an action")
172
+ if not source.startswith(("https://", "http://")):
173
+ raise ValueError(f"{where} needs an http(s) source URL")
174
+ advisories.append(
175
+ Advisory(
176
+ version=version,
177
+ severity=severity,
178
+ summary=summary,
179
+ action=action,
180
+ source=source,
181
+ )
182
+ )
183
+ return advisories
184
+
185
+
186
+ def load_flag_rules(client_id: str) -> list[FlagRule]:
187
+ path = data_dir() / "flags" / f"{client_id}.json"
188
+ if not path.exists():
189
+ return []
190
+ payload = _read_json(path)
191
+ rules: list[FlagRule] = []
192
+ for index, raw in enumerate(payload.get("rules", []), start=1):
193
+ if not isinstance(raw, dict):
194
+ raise ValueError(f"{path} rule {index} must be an object")
195
+ version_text = str(raw.get("version", "")).strip()
196
+ version = parse_version(version_text)
197
+ flag = str(raw.get("flag", "")).strip().removeprefix("--")
198
+ effect = str(raw.get("effect", "")).strip()
199
+ action = str(raw.get("action", "")).strip()
200
+ source = str(raw.get("source", "")).strip()
201
+ replacement = str(raw.get("replacement", "")).strip().removeprefix("--")
202
+ raw_values = raw.get("values", [])
203
+ where = f"{path} rule {index} ({flag or version_text or 'missing flag'})"
204
+ if version is None:
205
+ raise ValueError(f"{where} has a version ChainDiff cannot parse")
206
+ if effect not in FLAG_EFFECTS:
207
+ raise ValueError(f"{where} has an unknown effect")
208
+ if normalize_flag(flag) == "":
209
+ raise ValueError(f"{where} needs a flag")
210
+ if not action:
211
+ raise ValueError(f"{where} needs an action")
212
+ if effect == "renamed" and normalize_flag(replacement) == "":
213
+ raise ValueError(f"{where} needs a replacement")
214
+ if not source.startswith(("https://", "http://")):
215
+ raise ValueError(f"{where} needs an http(s) source URL")
216
+ if not isinstance(raw_values, list) or any(not isinstance(item, str) or not item.strip() for item in raw_values):
217
+ raise ValueError(f"{where} values must be a list of strings")
218
+ values = tuple(item.strip() for item in raw_values)
219
+ if effect == "default" and values:
220
+ raise ValueError(f"{where} matches a missing key, so it cannot list values")
221
+ rules.append(
222
+ FlagRule(
223
+ version=version,
224
+ flag=flag,
225
+ effect=effect,
226
+ action=action,
227
+ source=source,
228
+ replacement=replacement,
229
+ values=values,
230
+ )
231
+ )
232
+ return rules
233
+
234
+
235
+ def network_ids() -> list[str]:
236
+ directory = data_dir() / "networks"
237
+ if not directory.is_dir():
238
+ return []
239
+ return sorted(path.stem for path in directory.glob("*.json"))
240
+
241
+
242
+ def load_network(network_id: str) -> NetworkSchedule:
243
+ key = network_id.strip().lower()
244
+ path = data_dir() / "networks" / f"{key}.json"
245
+ if not path.exists():
246
+ known = ", ".join(network_ids()) or "none"
247
+ raise ValueError(f"Unknown network '{network_id}'. Known networks: {known}")
248
+ payload = _read_json(path)
249
+ if str(payload.get("id", "")).strip() != key:
250
+ raise ValueError(f"{path} id does not match the file name")
251
+ name = str(payload.get("name", "")).strip()
252
+ upgrade = payload.get("upgrade")
253
+ if not name or not isinstance(upgrade, dict):
254
+ raise ValueError(f"{path} needs a name and an upgrade object")
255
+ upgrade_name = str(upgrade.get("name", "")).strip()
256
+ source = str(upgrade.get("source", "")).strip()
257
+ summary = str(upgrade.get("summary", "")).strip()
258
+ order_summary = str(upgrade.get("order_summary", "")).strip()
259
+ order = upgrade.get("order")
260
+ warning = str(upgrade.get("warning", "")).strip()
261
+ if not upgrade_name or not summary or not order_summary:
262
+ raise ValueError(f"{path} needs an upgrade name, summary, and order summary")
263
+ if not source.startswith(("https://", "http://")):
264
+ raise ValueError(f"{path} needs an http(s) source URL")
265
+ if order is not None and order not in _NETWORK_ORDERS:
266
+ raise ValueError(f"{path} has an unknown upgrade order")
267
+ activation = _optional_time(path, upgrade.get("activation"), "activation")
268
+ clients = load_clients()
269
+ required = upgrade.get("required")
270
+ if not isinstance(required, dict):
271
+ raise ValueError(f"{path} needs a required object")
272
+ return NetworkSchedule(
273
+ id=key,
274
+ name=name,
275
+ upgrade=upgrade_name,
276
+ activation=activation,
277
+ source=source,
278
+ summary=summary,
279
+ order=order,
280
+ order_summary=order_summary,
281
+ warning=warning,
282
+ required_execution=_required_versions(path, required, "execution", clients),
283
+ required_consensus=_required_versions(path, required, "consensus", clients),
284
+ )
285
+
286
+
287
+ def _optional_time(path: Path, value: object, label: str) -> datetime | None:
288
+ if value is None:
289
+ return None
290
+ if not isinstance(value, str) or not value.strip():
291
+ raise ValueError(f"{path} has an unreadable {label}")
292
+ try:
293
+ return parse_time(value.strip())
294
+ except ValueError as exc:
295
+ raise ValueError(f"{path} has an unreadable {label}") from exc
296
+
297
+
298
+ def _required_versions(
299
+ path: Path,
300
+ required: dict,
301
+ role: str,
302
+ clients: list[Client],
303
+ ) -> dict[str, Version]:
304
+ raw = required.get(role)
305
+ if not isinstance(raw, dict):
306
+ raise ValueError(f"{path} needs required.{role}")
307
+ versions: dict[str, Version] = {}
308
+ for client_id, version_text in raw.items():
309
+ client = client_by_id(clients, str(client_id))
310
+ where = f"{path} required {role} {client_id}"
311
+ if client is None or client.role != role:
312
+ article = "an" if role[:1] in "aeiou" else "a"
313
+ raise ValueError(f"{where} is not {article} {role} client")
314
+ text = str(version_text).strip()
315
+ if _PRECISE_RELEASE.fullmatch(text) is None:
316
+ raise ValueError(f"{where} needs a major.minor.patch version")
317
+ version = parse_version(text)
318
+ if version is None:
319
+ raise ValueError(f"{where} has a version ChainDiff cannot parse")
320
+ versions[client.id] = version
321
+ return versions