@christang/keel 5.71.0 → 5.72.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.
package/README.md
CHANGED
|
@@ -271,8 +271,11 @@ designed to — resist widening the policy until it stops happening.
|
|
|
271
271
|
### Who merges
|
|
272
272
|
|
|
273
273
|
Keel never lets the agent merge — an unattended run opens a pull request and stops. A repository can
|
|
274
|
-
still merge without a person, on a rule of its own: GitHub auto-merge behind a required status check
|
|
275
|
-
|
|
274
|
+
still merge without a person, on a rule of its own: GitHub auto-merge behind a required status check,
|
|
275
|
+
or a workflow job that merges what passed. Keel's own repository does the second — the `land` job in
|
|
276
|
+
`.github/workflows/publish.yml` merges the owner's pull request once `full-gate` passed on its head
|
|
277
|
+
commit, then publishes and releases the version in the same run, with no stored secret. If your
|
|
278
|
+
repository merges on its own, say so:
|
|
276
279
|
|
|
277
280
|
```yaml
|
|
278
281
|
merge: repository:full-gate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.72.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.
|
|
3
|
+
"version": "5.72.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.
|
|
42
|
-
PROTOCOL_VERSION = "5.
|
|
41
|
+
PACKAGE_VERSION = "5.72.0"
|
|
42
|
+
PROTOCOL_VERSION = "5.72.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
|
|
@@ -29777,6 +29777,13 @@ def publish_serialization_problem(workflow: str) -> str | None:
|
|
|
29777
29777
|
"release would cancel a publish still waiting to run, losing that "
|
|
29778
29778
|
"version outright rather than appearing to."
|
|
29779
29779
|
)
|
|
29780
|
+
if not re.search(r"^[ \t]+queue:\s*max\s*$", body, re.M):
|
|
29781
|
+
return (
|
|
29782
|
+
"a pending publish would be cancelled by the next one — the group "
|
|
29783
|
+
"declares no `queue: max`, and the default `queue: single` keeps one "
|
|
29784
|
+
"pending run and cancels it when a newer one arrives, so a burst of "
|
|
29785
|
+
"three releases drops the middle version (#157)."
|
|
29786
|
+
)
|
|
29780
29787
|
group = re.search(r"^[ \t]+group:\s*(\S.*?)\s*$", body, re.M)
|
|
29781
29788
|
if not group:
|
|
29782
29789
|
return (
|
|
@@ -29835,6 +29842,25 @@ def validate_a_publish_waits_for_the_one_before_it_scenario() -> int:
|
|
|
29835
29842
|
)
|
|
29836
29843
|
return 1
|
|
29837
29844
|
|
|
29845
|
+
# The pending half (#157). `cancel-in-progress: false` protects the running
|
|
29846
|
+
# publish; the default `queue: single` still cancels a pending one when a
|
|
29847
|
+
# newer run arrives, so a burst of three releases drops the middle version.
|
|
29848
|
+
# A copy without `queue: max` is exactly what 5.70.0 shipped.
|
|
29849
|
+
unqueued = workflow.replace(" queue: max\n", "")
|
|
29850
|
+
if unqueued == workflow:
|
|
29851
|
+
report(
|
|
29852
|
+
f"{label}: a pending publish would be cancelled by the next one — "
|
|
29853
|
+
"`queue: max` could not be located to remove, so the real file keeps "
|
|
29854
|
+
"at most one pending run and cancels the rest."
|
|
29855
|
+
)
|
|
29856
|
+
return 1
|
|
29857
|
+
if not publish_serialization_problem(unqueued):
|
|
29858
|
+
report(
|
|
29859
|
+
f"{label}: a pending publish would be cancelled by the next one — a "
|
|
29860
|
+
"group without `queue: max` was accepted."
|
|
29861
|
+
)
|
|
29862
|
+
return 1
|
|
29863
|
+
|
|
29838
29864
|
# M2 — the option whose default is wrong for this job. A check satisfied by
|
|
29839
29865
|
# the block alone would pass on the configuration that loses a version
|
|
29840
29866
|
# outright, which is worse than the one being fixed here.
|
|
@@ -30002,6 +30028,172 @@ def validate_a_merge_names_who_makes_it_scenario() -> int:
|
|
|
30002
30028
|
return 0
|
|
30003
30029
|
|
|
30004
30030
|
|
|
30031
|
+
def workflow_job_block(workflow: str, job: str) -> str:
|
|
30032
|
+
"""The text of one job under `jobs:`, up to the next job at the same indent."""
|
|
30033
|
+
match = re.search(
|
|
30034
|
+
r"^ " + re.escape(job) + r":\n((?:(?: [^\n]*|[ \t]*)\n)*)",
|
|
30035
|
+
workflow,
|
|
30036
|
+
re.M,
|
|
30037
|
+
)
|
|
30038
|
+
return match.group(1) if match else ""
|
|
30039
|
+
|
|
30040
|
+
|
|
30041
|
+
def landing_problem(workflow: str) -> str | None:
|
|
30042
|
+
"""Return the problem the landing path in `publish.yml` has, or None.
|
|
30043
|
+
|
|
30044
|
+
Takes text so each broken shape can be planted. The workflow runs only on
|
|
30045
|
+
GitHub from the default branch; the repository's copy is the one input
|
|
30046
|
+
guaranteed to be correct, so a rule that only read it would never be seen to
|
|
30047
|
+
fire.
|
|
30048
|
+
"""
|
|
30049
|
+
land = workflow_job_block(workflow, "land")
|
|
30050
|
+
if not land:
|
|
30051
|
+
return (
|
|
30052
|
+
f"merges without confirming full-gate — {PUBLISH_WORKFLOW} has no "
|
|
30053
|
+
"`land` job."
|
|
30054
|
+
)
|
|
30055
|
+
if "check_name=full-gate" not in land:
|
|
30056
|
+
return (
|
|
30057
|
+
"merges without confirming full-gate — the `land` job never reads "
|
|
30058
|
+
"the `full-gate` check-run on the head commit before merging."
|
|
30059
|
+
)
|
|
30060
|
+
if "--match-head-commit" not in land:
|
|
30061
|
+
return (
|
|
30062
|
+
"merges without confirming full-gate — the merge is not pinned to the "
|
|
30063
|
+
"tested head commit, so a push after the check could land untested."
|
|
30064
|
+
)
|
|
30065
|
+
if "actions/checkout" in land:
|
|
30066
|
+
return (
|
|
30067
|
+
"pull request code could run with a write token — the `land` job "
|
|
30068
|
+
"checks out code, and it runs in the base repository's context with "
|
|
30069
|
+
"write permission."
|
|
30070
|
+
)
|
|
30071
|
+
if '.user.login == \\"$OWNER\\"' not in land:
|
|
30072
|
+
return (
|
|
30073
|
+
"pull request code could run with a write token — the `land` job "
|
|
30074
|
+
"does not restrict itself to the repository owner's pull requests."
|
|
30075
|
+
)
|
|
30076
|
+
if '.head.repo.full_name == \\"$REPO\\"' not in land:
|
|
30077
|
+
return (
|
|
30078
|
+
"pull request code could run with a write token — the `land` job "
|
|
30079
|
+
"does not refuse pull requests from forks."
|
|
30080
|
+
)
|
|
30081
|
+
publish = workflow_job_block(workflow, "publish")
|
|
30082
|
+
published_at = publish.find("npm publish")
|
|
30083
|
+
released_at = publish.find("gh release create")
|
|
30084
|
+
if published_at < 0 or released_at < 0:
|
|
30085
|
+
return (
|
|
30086
|
+
"a failed publish would be left tagged and never retried — the "
|
|
30087
|
+
"`publish` job lacks `npm publish` or `gh release create`."
|
|
30088
|
+
)
|
|
30089
|
+
if released_at < published_at:
|
|
30090
|
+
return (
|
|
30091
|
+
"a failed publish would be left tagged and never retried — the "
|
|
30092
|
+
"`publish` job creates the tag and release before `npm publish`, and "
|
|
30093
|
+
"a tag is what tells the next run the version is already released."
|
|
30094
|
+
)
|
|
30095
|
+
return None
|
|
30096
|
+
|
|
30097
|
+
|
|
30098
|
+
def validate_the_repository_lands_what_passed_scenario() -> int:
|
|
30099
|
+
"""#155 follow-up: the repository merges, not the agent, with no stored secret.
|
|
30100
|
+
|
|
30101
|
+
Events made with `GITHUB_TOKEN` start no new workflow runs, so a chain of
|
|
30102
|
+
workflows (merge, then release, then publish) needs a personal token at every
|
|
30103
|
+
hand-off. One run that merges, publishes and releases has no hand-off. The
|
|
30104
|
+
price is that the landing job runs with write permission in the base
|
|
30105
|
+
repository's context — so it must run no pull-request code, land only the
|
|
30106
|
+
owner's own pull requests, and merge only the commit `full-gate` passed.
|
|
30107
|
+
"""
|
|
30108
|
+
label = "the-repository-lands-what-passed"
|
|
30109
|
+
workflow = (ROOT / PUBLISH_WORKFLOW).read_text(encoding="utf-8")
|
|
30110
|
+
|
|
30111
|
+
problem = landing_problem(workflow)
|
|
30112
|
+
if problem:
|
|
30113
|
+
report(f"{label}: {problem}")
|
|
30114
|
+
return 1
|
|
30115
|
+
land = workflow_job_block(workflow, "land")
|
|
30116
|
+
if not land:
|
|
30117
|
+
report(
|
|
30118
|
+
f"{label}: merges without confirming full-gate — {PUBLISH_WORKFLOW} "
|
|
30119
|
+
"has no `land` job, so nothing lands a pull request that passed."
|
|
30120
|
+
)
|
|
30121
|
+
return 1
|
|
30122
|
+
|
|
30123
|
+
# M1 — the check is read and the tested commit is what merges.
|
|
30124
|
+
for needle, why in (
|
|
30125
|
+
("check_name=full-gate", "without reading the `full-gate` check-run"),
|
|
30126
|
+
("--match-head-commit", "without pinning the merge to the tested head commit"),
|
|
30127
|
+
):
|
|
30128
|
+
planted = workflow.replace(needle, "")
|
|
30129
|
+
if planted == workflow:
|
|
30130
|
+
report(f"{label}: merges without confirming full-gate — `{needle}` is absent.")
|
|
30131
|
+
return 1
|
|
30132
|
+
if not landing_problem(planted):
|
|
30133
|
+
report(
|
|
30134
|
+
f"{label}: merges without confirming full-gate — a landing job "
|
|
30135
|
+
f"that merges {why} was accepted."
|
|
30136
|
+
)
|
|
30137
|
+
return 1
|
|
30138
|
+
|
|
30139
|
+
# M2 — no pull-request code under a write token, and only the owner's own
|
|
30140
|
+
# pull requests from this repository. `workflow_run` and
|
|
30141
|
+
# `pull_request_target` run in the base repository's context with the
|
|
30142
|
+
# permissions the job asks for, so a checkout there hands the token to
|
|
30143
|
+
# whatever the pull request contains.
|
|
30144
|
+
checkout = workflow.replace(
|
|
30145
|
+
" - name: Land an owner's pull request whose head passed full-gate\n",
|
|
30146
|
+
" - uses: actions/checkout@v4\n"
|
|
30147
|
+
" - name: Land an owner's pull request whose head passed full-gate\n",
|
|
30148
|
+
)
|
|
30149
|
+
if checkout == workflow:
|
|
30150
|
+
report(f"{label}: pull request code could run with a write token — the land step could not be located.")
|
|
30151
|
+
return 1
|
|
30152
|
+
if not landing_problem(checkout):
|
|
30153
|
+
report(
|
|
30154
|
+
f"{label}: pull request code could run with a write token — a "
|
|
30155
|
+
"`land` job with a checkout step was accepted."
|
|
30156
|
+
)
|
|
30157
|
+
return 1
|
|
30158
|
+
for needle, why in (
|
|
30159
|
+
('.user.login == \\"$OWNER\\"', "any author's pull request"),
|
|
30160
|
+
('.head.repo.full_name == \\"$REPO\\"', "a pull request from a fork"),
|
|
30161
|
+
):
|
|
30162
|
+
planted = workflow.replace(" and " + needle, "")
|
|
30163
|
+
if planted == workflow:
|
|
30164
|
+
report(f"{label}: pull request code could run with a write token — `{needle}` is absent.")
|
|
30165
|
+
return 1
|
|
30166
|
+
if not landing_problem(planted):
|
|
30167
|
+
report(
|
|
30168
|
+
f"{label}: pull request code could run with a write token — a "
|
|
30169
|
+
f"`land` job that would land {why} was accepted."
|
|
30170
|
+
)
|
|
30171
|
+
return 1
|
|
30172
|
+
|
|
30173
|
+
# M3 — publish before tagging. A tag is what tells the next run "already
|
|
30174
|
+
# released", so a tag created before a publish that then fails marks a version
|
|
30175
|
+
# released that never reached npm, and nothing would ever retry it.
|
|
30176
|
+
publish_step = workflow[workflow.index(" - name: Publish\n"):workflow.index(" - name: Tag and release the landed version\n")]
|
|
30177
|
+
tag_step = workflow[workflow.index(" - name: Tag and release the landed version\n"):]
|
|
30178
|
+
swapped = workflow.replace(publish_step + tag_step, tag_step.rstrip("\n") + "\n\n" + publish_step.rstrip("\n") + "\n")
|
|
30179
|
+
if swapped == workflow:
|
|
30180
|
+
report(f"{label}: a failed publish would be left tagged and never retried — the two steps could not be swapped.")
|
|
30181
|
+
return 1
|
|
30182
|
+
if not landing_problem(swapped):
|
|
30183
|
+
report(
|
|
30184
|
+
f"{label}: a failed publish would be left tagged and never retried — "
|
|
30185
|
+
"a `publish` job that creates the release before `npm publish` was "
|
|
30186
|
+
"accepted."
|
|
30187
|
+
)
|
|
30188
|
+
return 1
|
|
30189
|
+
|
|
30190
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
30191
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
30192
|
+
return 1
|
|
30193
|
+
report(f"{label} scenario passed.")
|
|
30194
|
+
return 0
|
|
30195
|
+
|
|
30196
|
+
|
|
30005
30197
|
SCENARIOS: tuple = (
|
|
30006
30198
|
("stateless-continuity", validate_stateless_continuity_scenario),
|
|
30007
30199
|
("core-gates", validate_core_gates_scenario),
|
|
@@ -30368,6 +30560,10 @@ SCENARIOS: tuple = (
|
|
|
30368
30560
|
"an-equivalence-claim-names-its-base",
|
|
30369
30561
|
validate_an_equivalence_claim_names_its_base_scenario,
|
|
30370
30562
|
),
|
|
30563
|
+
(
|
|
30564
|
+
"the-repository-lands-what-passed",
|
|
30565
|
+
validate_the_repository_lands_what_passed_scenario,
|
|
30566
|
+
),
|
|
30371
30567
|
(
|
|
30372
30568
|
"a-merge-names-who-makes-it",
|
|
30373
30569
|
validate_a_merge_names_who_makes_it_scenario,
|