max / everycycle
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
1 file changed,
+305 insertions,
-4 deletions
| @@ -12,6 +12,16 @@ | |||
| 12 | 12 | ||
| 13 | 13 | python3 internal-deps.py ~/Code | |
| 14 | 14 | ||
| 15 | + | A fifth mode, run on a schedule rather than at push: | |
| 16 | + | ||
| 17 | + | python3 internal-deps.py ~/Code --release-tags [--json] | |
| 18 | + | ||
| 19 | + | grades the RELEASE TAGS instead of the branch, one per repo whose own | |
| 20 | + | `bento.toml` declares a release: the tag matching the version it is on today. | |
| 21 | + | See "the release-tag view" below for why the four views cannot see this. It is | |
| 22 | + | never run by the hook -- the answer changes while the repo sits still, and a | |
| 23 | + | push cannot fix a tag cut last week. | |
| 24 | + | ||
| 15 | 25 | The run grades FOUR views and fails on any of them: | |
| 16 | 26 | ||
| 17 | 27 | working copy what this machine builds today. The historical check. | |
| @@ -114,6 +124,14 @@ | |||
| 114 | 124 | SKIP_DIRS = { | |
| 115 | 125 | "target", ".git", "node_modules", "dist", "vendor", | |
| 116 | 126 | "_archive", "_scratch", "trash", "_meta", "vtebench", | |
| 127 | + | # Bento's release worktrees. Detached copies of tree code AT TAGS, re-pinned | |
| 128 | + | # with --force and never edited by a person, so grading them says nothing | |
| 129 | + | # about the tree -- and says it loudly, because a tag legitimately carries | |
| 130 | + | # the requirement that was current when it was cut. Measured 2026-08-31: | |
| 131 | + | # 11 of 131 manifests in ~/Code were Bento worktrees, and a copy of a | |
| 132 | + | # library under here can shadow the real one in the crate index, which turns | |
| 133 | + | # noise into a false verdict. | |
| 134 | + | ".bento", | |
| 117 | 135 | } | |
| 118 | 136 | MAX_DEPTH = 4 | |
| 119 | 137 | ||
| @@ -502,6 +520,198 @@ | |||
| 502 | 520 | yield p, name, req | |
| 503 | 521 | ||
| 504 | 522 | ||
| 523 | + | # --- the release-tag view ----------------------------------------------------- | |
| 524 | + | # | |
| 525 | + | # THE HOLE THIS CLOSES, and it is about WHEN the gate runs rather than what it | |
| 526 | + | # checks. Every view above grades a commit once, on the way out, and nothing | |
| 527 | + | # re-grades it when a sibling moves underneath it. A tag is the worst case, | |
| 528 | + | # because a tag is what a release builds from and a tag never moves. | |
| 529 | + | # | |
| 530 | + | # Measured 2026-08-31: Bento's release of mnw-cli 0.1.3 failed on astra with | |
| 531 | + | # | |
| 532 | + | # error: failed to select a version for the requirement `synckit-client = "^0.9"` | |
| 533 | + | # candidate versions found which didn't match: 0.10.0 | |
| 534 | + | # | |
| 535 | + | # `mnw-cli-v0.1.3` was cut before the commit that took synckit-client 0.10, so | |
| 536 | + | # the tagged tree still required ^0.9 while the pushed synckit was 0.10.0. Every | |
| 537 | + | # view was green when MNW was pushed, because ^0.9 was current then. The branch | |
| 538 | + | # had the forward fix; the tag did not, and Bento builds from the tag. | |
| 539 | + | # | |
| 540 | + | # It is invisible locally as well: `~/Code/.cargo/config.toml`'s `[patch]` block | |
| 541 | + | # redirects synckit-client to the working copy, so the stale requirement resolves | |
| 542 | + | # on fw13 and fails only where the patch is absent. | |
| 543 | + | # | |
| 544 | + | # WHICH TAGS. One per repo: the tag matching the version its own `bento.toml` | |
| 545 | + | # declares today. Grading every tag ever cut is unbounded and mostly pointless -- | |
| 546 | + | # a stale old tag nobody will rebuild is not a defect. A stale tag at the CURRENT | |
| 547 | + | # version is a broken release waiting to happen, and it makes the check answer a | |
| 548 | + | # question worth asking: would a release of this, right now, resolve? | |
| 549 | + | # | |
| 550 | + | # READ FROM THE REPO-SIDE `bento.toml`, not from `~/.config/bento/bento.toml`. | |
| 551 | + | # The daemon's config is machine state and is not in the tree, so a tree-level | |
| 552 | + | # tool cannot see it; the repo-side file is the durable declaration, is versioned | |
| 553 | + | # with the code it describes, and carries both fields this needs. | |
| 554 | + | # | |
| 555 | + | # A version with no tag yet is NOT a finding. It means the release has not been | |
| 556 | + | # cut, and what a release would cut is the branch head, which the views above | |
| 557 | + | # already grade. | |
| 558 | + | ||
| 559 | + | ||
| 560 | + | def bento_configs(tree): | |
| 561 | + | """Every repo-side `bento.toml` under `tree`, shallow-walked. | |
| 562 | + | ||
| 563 | + | One per PRODUCT, not one per repo: MNW is a single .git over pom, wam, | |
| 564 | + | magicmirror and mnw-cli, and each declares its own release with its own | |
| 565 | + | `tag_format`. Walking for the file rather than probing repo roots is what | |
| 566 | + | makes the monorepo's four visible. | |
| 567 | + | """ | |
| 568 | + | out = [] | |
| 569 | + | stack = [(tree, 0)] | |
| 570 | + | while stack: | |
| 571 | + | d, depth = stack.pop() | |
| 572 | + | try: | |
| 573 | + | entries = list(os.scandir(d)) | |
| 574 | + | except OSError: | |
| 575 | + | continue | |
| 576 | + | for e in entries: | |
| 577 | + | if e.is_file() and e.name == "bento.toml": | |
| 578 | + | out.append(e.path) | |
| 579 | + | elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH: | |
| 580 | + | stack.append((e.path, depth + 1)) | |
| 581 | + | return out | |
| 582 | + | ||
| 583 | + | ||
| 584 | + | def bento_version(product, version_path): | |
| 585 | + | """A product's release version, following Bento's own resolution rules. | |
| 586 | + | ||
| 587 | + | Mirrors `AppConfig::version_path` in `bento/daemon/src/topology.rs`: unset | |
| 588 | + | means `src-tauri/tauri.conf.json` and then the root `Cargo.toml`, a `.json` | |
| 589 | + | file is read as a tauri config, and anything else as a `Cargo.toml`. Most | |
| 590 | + | products declare nothing here -- the field lives in the daemon's config, not | |
| 591 | + | the repo's -- so getting the default right is what makes the check cover the | |
| 592 | + | tree rather than the four products that spell it out. | |
| 593 | + | """ | |
| 594 | + | candidates = ( | |
| 595 | + | [version_path] | |
| 596 | + | if isinstance(version_path, str) | |
| 597 | + | else ["src-tauri/tauri.conf.json", "Cargo.toml"] | |
| 598 | + | ) | |
| 599 | + | for rel in candidates: | |
| 600 | + | full = os.path.join(product, rel) | |
| 601 | + | if not os.path.exists(full): | |
| 602 | + | continue | |
| 603 | + | if rel.endswith(".json"): | |
| 604 | + | try: | |
| 605 | + | with open(full, encoding="utf-8") as fh: | |
| 606 | + | v = json.load(fh).get("version") | |
| 607 | + | except (OSError, ValueError): | |
| 608 | + | continue | |
| 609 | + | if isinstance(v, str): | |
| 610 | + | return v | |
| 611 | + | continue | |
| 612 | + | doc = load(full) | |
| 613 | + | pkg = (doc or {}).get("package") | |
| 614 | + | if isinstance(pkg, dict) and isinstance(pkg.get("version"), str): | |
| 615 | + | return pkg["version"] | |
| 616 | + | ws = ((doc or {}).get("workspace") or {}).get("package") or {} | |
| 617 | + | if isinstance(ws.get("version"), str): | |
| 618 | + | return ws["version"] | |
| 619 | + | return None | |
| 620 | + | ||
| 621 | + | ||
| 622 | + | def bento_release_tag(cfg_path): | |
| 623 | + | """The tag a release declared by `cfg_path` would build from today, or None. | |
| 624 | + | ||
| 625 | + | Returns the tag string whether or not it exists; existence is the caller's | |
| 626 | + | question, and a version with no tag is not a finding -- it means the release | |
| 627 | + | has not been cut, and what a release would cut is the branch head, which the | |
| 628 | + | views above already grade. That is also what makes the defaults above safe to | |
| 629 | + | apply: a wrong guess names a tag that does not exist, so it can only ever | |
| 630 | + | lose coverage, never invent a failure. | |
| 631 | + | """ | |
| 632 | + | try: | |
| 633 | + | with open(cfg_path, "rb") as fh: | |
| 634 | + | cfg = tomllib.load(fh) | |
| 635 | + | except (OSError, tomllib.TOMLDecodeError): | |
| 636 | + | return None | |
| 637 | + | version = bento_version(os.path.dirname(cfg_path), cfg.get("version_path")) | |
| 638 | + | if not version: | |
| 639 | + | return None | |
| 640 | + | # `v{version}` is Bento's default; a repo holding several products spells it | |
| 641 | + | # per product (`mnw-cli-v{version}`), which is why this is read rather than | |
| 642 | + | # assumed. | |
| 643 | + | fmt = cfg.get("tag_format") | |
| 644 | + | if not isinstance(fmt, str): | |
| 645 | + | fmt = "v{version}" | |
| 646 | + | return fmt.replace("{version}", version) | |
| 647 | + | ||
| 648 | + | ||
| 649 | + | def release_tags(tree, only=None): | |
| 650 | + | """(repo, product, tag) for every declared release whose tag exists, plus notes. | |
| 651 | + | ||
| 652 | + | `only` narrows to the products under one path, which is how a per-repo caller | |
| 653 | + | (the sweep) gets a cell about its own repo instead of the whole tree's. | |
| 654 | + | ||
| 655 | + | Notes carry the products that declare a release and have no tag for the | |
| 656 | + | version they are on. Not findings -- an uncut release is the normal state | |
| 657 | + | between them -- but counted, because "nothing to grade" and "all clear" are | |
| 658 | + | different answers and a check that conflates them is not evidence. | |
| 659 | + | """ | |
| 660 | + | pairs, uncut = [], {} | |
| 661 | + | for cfg in sorted(bento_configs(tree)): | |
| 662 | + | product = os.path.dirname(cfg) | |
| 663 | + | if only and not (product == only or product.startswith(only + os.sep)): | |
| 664 | + | continue | |
| 665 | + | repo = repo_of(cfg, tree) | |
| 666 | + | if repo is None: | |
| 667 | + | continue | |
| 668 | + | tag = bento_release_tag(cfg) | |
| 669 | + | if tag is None: | |
| 670 | + | continue | |
| 671 | + | if git_lines(repo, "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"): | |
| 672 | + | pairs.append((repo, product, tag)) | |
| 673 | + | else: | |
| 674 | + | uncut[product] = tag | |
| 675 | + | return pairs, uncut | |
| 676 | + | ||
| 677 | + | ||
| 678 | + | def analyze_release_tags(disk_docs, tree, only=None): | |
| 679 | + | """Grade each release tag's own manifests against what siblings have pushed. | |
| 680 | + | ||
| 681 | + | The consumer view is the tagged tree and nothing else: this asks whether THAT | |
| 682 | + | tag resolves, not whether the working copy does, and the working copy is | |
| 683 | + | already three views above. Providers are read at their remote refs by | |
| 684 | + | `analyze_published`, which is the same machinery and the same no-network | |
| 685 | + | happy path. | |
| 686 | + | ||
| 687 | + | Scoped to the PRODUCT's directory inside the tag, not to the whole repo. At | |
| 688 | + | `mnw-cli-v0.1.4` the entire MNW tree exists, and grading all of it would | |
| 689 | + | report pom's requirements as mnw-cli's release problem. | |
| 690 | + | """ | |
| 691 | + | pairs, uncut = release_tags(tree, only) | |
| 692 | + | broken, graded, ungraded = [], 0, {} | |
| 693 | + | for repo, product, tag in pairs: | |
| 694 | + | rels = git_manifests(repo, tag) | |
| 695 | + | if rels is None: | |
| 696 | + | ungraded.setdefault(product, f"{tag} is not readable") | |
| 697 | + | continue | |
| 698 | + | prefix = os.path.relpath(product, repo) | |
| 699 | + | prefix = "" if prefix == "." else prefix + os.sep | |
| 700 | + | tagged = {} | |
| 701 | + | for rel in rels: | |
| 702 | + | if prefix and not rel.startswith(prefix): | |
| 703 | + | continue | |
| 704 | + | tagged[os.path.join(repo, rel)] = load_at(repo, tag, rel) | |
| 705 | + | if not tagged: | |
| 706 | + | ungraded.setdefault(product, f"{tag} carries no manifest under {prefix or '.'}") | |
| 707 | + | continue | |
| 708 | + | b, g, u = analyze_published(tagged, disk_docs, tree, None, None) | |
| 709 | + | broken += [(*row, tag) for row in b] | |
| 710 | + | graded += g | |
| 711 | + | ungraded.update(u) | |
| 712 | + | return broken, graded, ungraded, pairs, uncut | |
| 713 | + | ||
| 714 | + | ||
| 505 | 715 | # --- the crates.io view ------------------------------------------------------- | |
| 506 | 716 | ||
| 507 | 717 | # Cargo's own sparse-index cache. One file per crate, holding the same | |
| @@ -779,13 +989,104 @@ | |||
| 779 | 989 | return True | |
| 780 | 990 | ||
| 781 | 991 | ||
| 992 | + | def release_tag_report(tree, as_json, only=None): | |
| 993 | + | """The release-tag view on its own, for the scheduled caller. | |
| 994 | + | ||
| 995 | + | Separate from `main`'s four views and never run at push time. The question is | |
| 996 | + | "would a release of this resolve right now", and the answer changes while the | |
| 997 | + | repo sits still -- which makes it a nightly's question, not a hook's. A hook | |
| 998 | + | that refused a push over a tag cut last week would also be refusing it for | |
| 999 | + | something the push cannot fix. | |
| 1000 | + | """ | |
| 1001 | + | docs = {p: load(p) for p in manifests(tree)} | |
| 1002 | + | broken, graded, ungraded, pairs, uncut = analyze_release_tags(docs, tree, only) | |
| 1003 | + | ||
| 1004 | + | if as_json: | |
| 1005 | + | # witchbroom's `json` capture: one finding per requirement the tag cannot | |
| 1006 | + | # resolve, under a key the grid's parser knows. | |
| 1007 | + | print(json.dumps({ | |
| 1008 | + | "release_tags": [ | |
| 1009 | + | { | |
| 1010 | + | "ok": False, | |
| 1011 | + | "kind": "tag-unresolvable", | |
| 1012 | + | "message": ( | |
| 1013 | + | f"{tag}: {os.path.relpath(consumer, tree)} requires " | |
| 1014 | + | f"{name} {req}, and {ref} has {have}" | |
| 1015 | + | ), | |
| 1016 | + | "path": os.path.relpath(consumer, tree), | |
| 1017 | + | } | |
| 1018 | + | for consumer, name, req, have, _provider, ref, tag in broken | |
| 1019 | + | ], | |
| 1020 | + | "tags_graded": [ | |
| 1021 | + | {"product": os.path.relpath(prod, tree), "tag": t} | |
| 1022 | + | for _repo, prod, t in pairs | |
| 1023 | + | ], | |
| 1024 | + | # Counted rather than silent, the `coherence` reading exactly: a repo | |
| 1025 | + | # between releases has no tag at its current version, and that is a | |
| 1026 | + | # different answer from a tag that resolves. | |
| 1027 | + | "uncut": [ | |
| 1028 | + | {"product": os.path.relpath(prod, tree), "tag": t} | |
| 1029 | + | for prod, t in sorted(uncut.items()) | |
| 1030 | + | ], | |
| 1031 | + | "requirements_graded": graded, | |
| 1032 | + | "ungraded": {os.path.relpath(k, tree): v for k, v in ungraded.items()}, | |
| 1033 | + | }, indent=2)) | |
| 1034 | + | return 1 if broken else 0 | |
| 1035 | + | ||
| 1036 | + | for consumer, name, req, have, _provider, ref, tag in broken: | |
| 1037 | + | print( | |
| 1038 | + | f"{tag}: {os.path.relpath(consumer, tree)} requires {name} {req}; " | |
| 1039 | + | f"{ref} has {have}", | |
| 1040 | + | file=sys.stderr, | |
| 1041 | + | ) | |
| 1042 | + | if broken: | |
| 1043 | + | print( | |
| 1044 | + | "\nA release cut from one of the tags above cannot resolve. The tag was\n" | |
| 1045 | + | "coherent when it was cut and a sibling has moved since; a tag never\n" | |
| 1046 | + | "moves, so the fix is a new tag at a version whose manifests carry the\n" | |
| 1047 | + | "current requirement, not an edit to this one.", | |
| 1048 | + | file=sys.stderr, | |
| 1049 | + | ) | |
| 1050 | + | return 1 | |
| 1051 | + | print( | |
| 1052 | + | f"release tags coherent ({len(pairs)} tags, {graded} requirements" | |
| 1053 | + | + (f", {len(uncut)} versions not yet tagged" if uncut else "") | |
| 1054 | + | + ")." | |
| 1055 | + | ) | |
| 1056 | + | for where, why in sorted(ungraded.items()): | |
| 1057 | + | print(f"[release tags] {os.path.relpath(where, tree)} not graded ({why}).") | |
| 1058 | + | return 0 | |
| 1059 | + | ||
| 1060 | + | ||
| 782 | 1061 | def main(): | |
| 783 | - | if len(sys.argv) < 2: | |
| 1062 | + | raw = sys.argv[1:] | |
| 1063 | + | args, flags, skip = [], set(), False | |
| 1064 | + | for i, a in enumerate(raw): | |
| 1065 | + | if skip: | |
| 1066 | + | skip = False | |
| 1067 | + | continue | |
| 1068 | + | if a.startswith("--"): | |
| 1069 | + | flags.add(a) | |
| 1070 | + | # The one flag that takes a value. Consuming it here keeps its path | |
| 1071 | + | # out of the positional list, where it would be read as the tree. | |
| 1072 | + | if a == "--only": | |
| 1073 | + | skip = True | |
| 1074 | + | else: | |
| 1075 | + | args.append(a) | |
| 1076 | + | if not args: | |
| 784 | 1077 | print(__doc__.strip(), file=sys.stderr) | |
| 785 | 1078 | return 2 | |
| 786 | - | tree = os.path.realpath(sys.argv[1]) | |
| 787 | - | repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None | |
| 788 | - | sha = sys.argv[3] if len(sys.argv) > 3 else None | |
| 1079 | + | tree = os.path.realpath(args[0]) | |
| 1080 | + | if "--release-tags" in flags: | |
| 1081 | + | # `--only <path>` narrows to one repo or product, for a per-repo caller. | |
| 1082 | + | only = None | |
| 1083 | + | if "--only" in sys.argv: | |
| 1084 | + | i = sys.argv.index("--only") | |
| 1085 | + | if i + 1 < len(sys.argv): | |
| 1086 | + | only = os.path.realpath(sys.argv[i + 1]) | |
| 1087 | + | return release_tag_report(tree, "--json" in flags, only) | |
| 1088 | + | repo = os.path.realpath(args[1]) if len(args) > 1 else None | |
| 1089 | + | sha = args[2] if len(args) > 2 else None | |
| 789 | 1090 | ||
| 790 | 1091 | docs = {p: load(p) for p in manifests(tree)} | |
| 791 | 1092 |