@christang/keel 5.69.0 → 5.70.0

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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.69.0 -->
1
+ <!-- keel:start version=5.70.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.69.0",
5
+ "version": "5.70.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.69.0",
3
+ "version": "5.70.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.69.0",
3
+ "version": "5.70.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.69.0"
42
- PROTOCOL_VERSION = "5.69.0"
41
+ PACKAGE_VERSION = "5.70.0"
42
+ PROTOCOL_VERSION = "5.70.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -29697,6 +29697,138 @@ def validate_an_equivalence_claim_names_its_base_scenario() -> int:
29697
29697
  return 0
29698
29698
 
29699
29699
 
29700
+ # The workflow serializes, or it does not. Asserted by reading the file, because
29701
+ # it runs only on GitHub, only on a `release` event, and nothing local can
29702
+ # exercise it — which is exactly why an edit could drop the declaration and
29703
+ # nobody would find out until the next day someone cut several releases at once.
29704
+ PUBLISH_WORKFLOW = ".github/workflows/publish.yml"
29705
+
29706
+
29707
+ def publish_serialization_problem(workflow: str) -> str | None:
29708
+ """Return the problem the publish workflow's ordering has, or None.
29709
+
29710
+ Takes the text rather than reading the path, so each broken shape can be
29711
+ exercised on a planted copy. A rule that could only ever see the file already
29712
+ known to be correct would pass forever without anyone learning whether it
29713
+ fires.
29714
+ """
29715
+ block = re.search(
29716
+ r"^concurrency:\n((?:[ \t]+\S[^\n]*\n)+)", workflow, re.M
29717
+ )
29718
+ if not block:
29719
+ return (
29720
+ "simultaneous releases publish concurrently — "
29721
+ f"{PUBLISH_WORKFLOW} declares no `concurrency:` group, so every "
29722
+ "release event starts its own publish against the same package."
29723
+ )
29724
+ body = block.group(1)
29725
+ if not re.search(r"^[ \t]+cancel-in-progress:\s*false\s*$", body, re.M):
29726
+ return (
29727
+ "a queued publish would be cancelled — the group declares no "
29728
+ "`cancel-in-progress: false`, and the default is `true`: the next "
29729
+ "release would cancel a publish still waiting to run, losing that "
29730
+ "version outright rather than appearing to."
29731
+ )
29732
+ group = re.search(r"^[ \t]+group:\s*(\S.*?)\s*$", body, re.M)
29733
+ if not group:
29734
+ return (
29735
+ "a per-run group serializes nothing — the `concurrency:` block "
29736
+ "names no `group:`, so there is nothing for a run to queue behind."
29737
+ )
29738
+ if "${{" in group.group(1):
29739
+ return (
29740
+ "a per-run group serializes nothing — the group expression "
29741
+ f"`{group.group(1)}` is evaluated per run, so each release gets a "
29742
+ "group of its own and none of them wait. The thing being protected "
29743
+ "is the package, and there is one of those."
29744
+ )
29745
+ return None
29746
+
29747
+
29748
+ def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
29749
+ """Issue #153: eleven releases at once, and a registry that lied about it.
29750
+
29751
+ Nothing was lost — every job logged its own `+ @christang/keel@<version>`,
29752
+ and the write side proved it by refusing a re-run with `You cannot publish
29753
+ over the previously published versions`. What broke was the read side: the
29754
+ packument reported published versions as missing, and the set changed
29755
+ between reads. Two re-runs were triggered on that false reading and are now
29756
+ red rows against a release that succeeded. Serialized, each publish finishes
29757
+ before the next begins and the window does not exist.
29758
+ """
29759
+ label = "a-publish-waits-for-the-one-before-it"
29760
+ path = ROOT / PUBLISH_WORKFLOW
29761
+ if not path.is_file():
29762
+ report(f"{label}: {PUBLISH_WORKFLOW} is missing.")
29763
+ return 1
29764
+ workflow = path.read_text(encoding="utf-8")
29765
+
29766
+ # M1 — the real file declares it, and a copy with the block removed does not.
29767
+ # The removal is the state the file was actually in on 2026-09-25.
29768
+ problem = publish_serialization_problem(workflow)
29769
+ if problem:
29770
+ report(f"{label}: {problem}")
29771
+ return 1
29772
+ stripped = re.sub(
29773
+ r"^concurrency:\n(?:[ \t]+\S[^\n]*\n)+", "", workflow, flags=re.M
29774
+ )
29775
+ if stripped == workflow:
29776
+ report(
29777
+ f"{label}: simultaneous releases publish concurrently — no "
29778
+ "`concurrency:` block could be located to remove, so the real file "
29779
+ "does not declare one and the check has nothing to prove."
29780
+ )
29781
+ return 1
29782
+ if not publish_serialization_problem(stripped):
29783
+ report(
29784
+ f"{label}: simultaneous releases publish concurrently — a workflow "
29785
+ "with no concurrency group was accepted, which is the configuration "
29786
+ "that let eleven releases publish at once."
29787
+ )
29788
+ return 1
29789
+
29790
+ # M2 — the option whose default is wrong for this job. A check satisfied by
29791
+ # the block alone would pass on the configuration that loses a version
29792
+ # outright, which is worse than the one being fixed here.
29793
+ cancelling = workflow.replace(" cancel-in-progress: false\n", "")
29794
+ if cancelling == workflow:
29795
+ report(
29796
+ f"{label}: a queued publish would be cancelled — "
29797
+ "`cancel-in-progress: false` could not be located to remove, so the "
29798
+ "real file does not declare it."
29799
+ )
29800
+ return 1
29801
+ if not publish_serialization_problem(cancelling):
29802
+ report(
29803
+ f"{label}: a queued publish would be cancelled — a group without "
29804
+ "`cancel-in-progress: false` was accepted, and the default cancels "
29805
+ "a queued publish when the next release fires."
29806
+ )
29807
+ return 1
29808
+
29809
+ # M3 — the shape that looks like a fix and is not. A group keyed per release
29810
+ # gives every run its own group, so nothing ever queues behind anything.
29811
+ per_ref = workflow.replace(
29812
+ " group: publish\n", " group: publish-${{ github.ref }}\n"
29813
+ )
29814
+ if per_ref == workflow:
29815
+ report(f"{label}: a per-run group serializes nothing — `group: publish` could not be located to vary.")
29816
+ return 1
29817
+ if not publish_serialization_problem(per_ref):
29818
+ report(
29819
+ f"{label}: a per-run group serializes nothing — a group expression "
29820
+ "that differs per release was accepted, so each publish gets a group "
29821
+ "of its own and none of them wait."
29822
+ )
29823
+ return 1
29824
+
29825
+ if label not in {name for name, _ in SCENARIOS}:
29826
+ report(f"{label}: the scenario registry does not include it.")
29827
+ return 1
29828
+ report(f"{label} scenario passed.")
29829
+ return 0
29830
+
29831
+
29700
29832
  SCENARIOS: tuple = (
29701
29833
  ("stateless-continuity", validate_stateless_continuity_scenario),
29702
29834
  ("core-gates", validate_core_gates_scenario),
@@ -30063,6 +30195,10 @@ SCENARIOS: tuple = (
30063
30195
  "an-equivalence-claim-names-its-base",
30064
30196
  validate_an_equivalence_claim_names_its_base_scenario,
30065
30197
  ),
30198
+ (
30199
+ "a-publish-waits-for-the-one-before-it",
30200
+ validate_a_publish_waits_for_the_one_before_it_scenario,
30201
+ ),
30066
30202
  (
30067
30203
  "guidance-is-referenced-and-carries-no-criterion",
30068
30204
  validate_guidance_is_referenced_and_carries_no_criterion_scenario,