Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/cmux-tui-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,27 @@ jobs:
fi
echo "owns_key=$owns_key" >> "$GITHUB_OUTPUT"

# Observation only: an unavailable marker must never hold up a build.
- name: Record tree owner observation
id: tree-source
continue-on-error: true
env:
TREE_KEY: ${{ steps.inspect.outputs.key }}
OWNS_KEY: ${{ steps.inspect.outputs.owns_key }}
TREE_READY: ${{ steps.inspect.outputs.tree_ready }}
run: python3 scripts/ci/nightly_tree_source.py owner
- name: Upload tree owner observation
if: steps.tree-source.outputs.name != ''
continue-on-error: true
timeout-minutes: 1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.tree-source.outputs.name }}
path: ${{ steps.tree-source.outputs.path }}
if-no-files-found: error
overwrite: true
retention-days: 3

tree-owner-wait:
name: wait for same-key tree publisher
# A superseded push (tree-preflight's stale step: a moved lane branch, a
Expand Down Expand Up @@ -731,6 +752,7 @@ jobs:
sparse-checkout: |
scripts/ci/cmux-tui-tree-claim.sh
scripts/ci/cmux_tui_publisher_testing.py
scripts/ci/nightly_tree_source.py
sparse-checkout-cone-mode: false
- name: Wait for the owning tree publisher
id: wait
Expand Down Expand Up @@ -1068,6 +1090,27 @@ jobs:
fi
echo "takeover=$takeover" >> "$GITHUB_OUTPUT"

# Observation only: an unavailable marker must never hold up a build.
- name: Record tree owner observation
id: tree-source
continue-on-error: true
env:
TREE_KEY: ${{ needs.tree-preflight.outputs.key }}
OWNS_KEY: ${{ steps.recheck.outputs.takeover == 'true' && 'true' || steps.wait.outputs.takeover }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This observation step runs a script that is only checked out when owner_run_id is set. When owner_run_id is empty, the script is missing and the step fails on every run, showing red in an otherwise green job. Gate the step on owner_run_id != '', which is the only case where the owner marker can be produced.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/cmux-tui-artifacts.yml, line 1099:

<comment>This observation step runs a script that is only checked out when `owner_run_id` is set. When `owner_run_id` is empty, the script is missing and the step fails on every run, showing red in an otherwise green job. Gate the step on `owner_run_id != ''`, which is the only case where the owner marker can be produced.</comment>

<file context>
@@ -1068,6 +1090,27 @@ jobs:
+        continue-on-error: true
+        env:
+          TREE_KEY: ${{ needs.tree-preflight.outputs.key }}
+          OWNS_KEY: ${{ steps.recheck.outputs.takeover == 'true' && 'true' || steps.wait.outputs.takeover }}
+          TREE_READY: ${{ needs.tree-preflight.outputs.tree_ready }}
+        run: python3 scripts/ci/nightly_tree_source.py owner
</file context>

TREE_READY: ${{ needs.tree-preflight.outputs.tree_ready }}
run: python3 scripts/ci/nightly_tree_source.py owner
- name: Upload tree owner observation
if: steps.tree-source.outputs.name != ''
continue-on-error: true
timeout-minutes: 1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.tree-source.outputs.name }}
path: ${{ steps.tree-source.outputs.path }}
if-no-files-found: error
overwrite: true
retention-days: 3

build:
if: >-
${{ !cancelled() && needs.tree-preflight.result == 'success' && needs.tree-owner-wait.result != 'failure' &&
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3348,6 +3348,26 @@ jobs:
echo "- tip: \`$TIP_SHA\` (${BEHIND:-0} commits and ${BEHIND_HOURS:-0} h behind)"
echo "- already published: $already_published"
} >> "$GITHUB_STEP_SUMMARY"
# The selected build source can differ from this workflow's control SHA.
- name: Record pending tree dependency
id: tree-source
continue-on-error: true
env:
TREE_PENDING: ${{ steps.pending.outputs.tree_pending }}
TREE_KEY: ${{ steps.pending.outputs.key }}
BUILD_SHA: ${{ steps.build.outputs.build_sha }}
run: python3 scripts/ci/nightly_tree_source.py dependency
- name: Upload pending tree dependency
if: steps.tree-source.outputs.name != ''
continue-on-error: true
timeout-minutes: 1
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.tree-source.outputs.name }}
path: ${{ steps.tree-source.outputs.path }}
if-no-files-found: error
overwrite: true
retention-days: 3
- name: Compute portable OptChat Chief input key
id: chief-key
if: needs.decide.outputs.track == 'nightly-next' && inputs.nightly_next_unsigned_run == ''
Expand Down
56 changes: 56 additions & 0 deletions scripts/ci/nightly_tree_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Publish observational tree lineage; never contact or change the claim authority.

Artifact metadata names join nightly dependencies to publisher runs. Owner names
are stable for exact-name lookup; readers must check JSON run_attempt against the
run's current attempt before trusting one. A missing marker means unknown.
"""
import json
import os
from pathlib import Path
import re
import sys


def marker(kind, env):
if kind == "dependency":
if env.get("TREE_PENDING") != "true":
return None
elif kind == "owner":
if env.get("OWNS_KEY") != "true" or env.get("TREE_READY") != "false":
return None
else:
raise ValueError("unknown tree marker kind")
key = env.get("TREE_KEY", "")
if not re.fullmatch(r"[0-9a-f]{40}", key):
raise ValueError("canonical v2 tree key must be 40 hex characters")
value = {"schema_version": 1, "kind": kind, "key": key}
for source, dest in (("GITHUB_RUN_ID", "run_id"), ("GITHUB_RUN_ATTEMPT", "run_attempt")):
raw = env.get(source, "")
if not re.fullmatch(r"[1-9][0-9]*", raw):
raise ValueError(f"{source} must be a positive integer")
value[dest] = int(raw)
if kind == "dependency":
sha = env.get("BUILD_SHA", "")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("build SHA must be 40 hex characters")
value["build_sha"] = sha
name = f"nightly-tree-dependency-{key}-{sha}-attempt-{value['run_attempt']}"
else:
name = f"cmux-tui-tree-owner-{key}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The docstring tells readers to match only run_attempt, but the owner name has no run ID, so exact-name lookups return markers from earlier runs. Those markers pass an attempt-only check whenever the attempt numbers match. Readers must also match JSON run_id against the current run, and the docstring should say so.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/ci/nightly_tree_source.py, line 40:

<comment>The docstring tells readers to match only `run_attempt`, but the owner name has no run ID, so exact-name lookups return markers from earlier runs. Those markers pass an attempt-only check whenever the attempt numbers match. Readers must also match JSON `run_id` against the current run, and the docstring should say so.</comment>

<file context>
@@ -0,0 +1,56 @@
+        value["build_sha"] = sha
+        name = f"nightly-tree-dependency-{key}-{sha}-attempt-{value['run_attempt']}"
+    else:
+        name = f"cmux-tui-tree-owner-{key}"
+    return name, value
+
</file context>

return name, value


def main():
result = marker(sys.argv[1], os.environ)
if result is None:
return
name, value = result
path = Path(os.environ["RUNNER_TEMP"]) / f"tree-{value['kind']}.json"
path.write_text(json.dumps(value, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write(f"name={name}\npath={path}\n")


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2382,3 +2382,8 @@ reason = "Shell-script unit test (no app, no network): run it with python3 -I be
[[test]]
path = "tests/test_layout_grid.py"
lane = "linux-guard"

[[test]]
path = "tests/test_nightly_tree_source_marker.py"
lane = "manual"
reason = "Portable source-contract test; run python3 tests/test_nightly_tree_source_marker.py through cmux-ci when tree observations change. No new CI gate."
97 changes: 97 additions & 0 deletions tests/test_nightly_tree_source_marker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Exercise the observations that the NOW artifact-tree reader consumes."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

SCRIPT = Path(__file__).resolve().parents[1] / "scripts/ci/nightly_tree_source.py"
KEY = "c" * 40
SHA = "9b1eb696154eb725069d18bed02902c04cdc4494"


class TreeSource(unittest.TestCase):
def invoke(self, kind, **changes):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "output"
output.touch()
env = dict(os.environ, RUNNER_TEMP=tmp, GITHUB_OUTPUT=str(output),
GITHUB_RUN_ID="41275955555", GITHUB_RUN_ATTEMPT="2",
TREE_PENDING="true", TREE_READY="false", OWNS_KEY="true",
TREE_KEY=KEY, BUILD_SHA=SHA, GITHUB_SHA="a" * 40)
env.update(changes)
result = subprocess.run([sys.executable, str(SCRIPT), kind], env=env,
capture_output=True, text=True)
values = dict(line.split("=", 1) for line in output.read_text().splitlines())
files = {p.name: json.loads(p.read_text()) for p in Path(tmp).glob("tree-*.json")}
return result, values, files

def test_pending_dependency_is_bound_to_build_not_control_sha(self):
result, values, files = self.invoke("dependency")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(values["name"], f"nightly-tree-dependency-{KEY}-{SHA}-attempt-2")
self.assertEqual(files, {"tree-dependency.json": {
"schema_version": 1, "kind": "dependency", "key": KEY,
"build_sha": SHA, "run_id": 41275955555, "run_attempt": 2}})
self.assertEqual(Path(values["path"]).name, "tree-dependency.json")

def test_nonpending_and_unknown_dependency_publish_nothing(self):
for pending in ("false", "", "unknown"):
with self.subTest(pending=pending):
result, values, files = self.invoke("dependency", TREE_PENDING=pending)
self.assertEqual((result.returncode, values, files), (0, {}, {}))

def test_owner_is_exact_key_lookup_with_current_attempt_payload(self):
result, values, files = self.invoke("owner", GITHUB_RUN_ID="41275912151", GITHUB_RUN_ATTEMPT="1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(values["name"], f"cmux-tui-tree-owner-{KEY}")
self.assertEqual(files, {"tree-owner.json": {
"schema_version": 1, "kind": "owner", "key": KEY,
"run_id": 41275912151, "run_attempt": 1}})
self.assertEqual(Path(values["path"]).name, "tree-owner.json")

def test_rerun_replaces_attempt_payload_without_changing_owner_lookup(self):
_, first, old_files = self.invoke("owner", GITHUB_RUN_ATTEMPT="1")
_, second, new_files = self.invoke("owner", GITHUB_RUN_ATTEMPT="2")
self.assertEqual(first["name"], second["name"])
self.assertEqual(old_files["tree-owner.json"]["run_attempt"], 1)
self.assertEqual(new_files["tree-owner.json"]["run_attempt"], 2)

def test_waiting_and_unknown_ownership_publish_nothing(self):
for owns in ("false", "", "unknown"):
with self.subTest(owns=owns):
result, values, files = self.invoke("owner", OWNS_KEY=owns)
self.assertEqual((result.returncode, values, files), (0, {}, {}))

def test_ready_or_unknown_tree_cannot_be_a_build_blocker(self):
for ready in ("true", "", "unknown"):
with self.subTest(ready=ready):
result, values, files = self.invoke("owner", TREE_READY=ready)
self.assertEqual((result.returncode, values, files), (0, {}, {}))

def test_canonical_key_validation_prevents_ambiguous_metadata(self):
for key in ("", "x" * 40, "a" * 39, "v2-" + KEY, KEY + "\nname=other"):
with self.subTest(key=key):
result, values, files = self.invoke("owner", TREE_KEY=key)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((values, files), ({}, {}))

def test_missing_build_source_does_not_fall_back_to_control_sha(self):
result, values, files = self.invoke("dependency", BUILD_SHA="")
self.assertNotEqual(result.returncode, 0)
self.assertEqual((values, files), ({}, {}))

def test_invalid_run_or_attempt_cannot_publish(self):
for field in ("GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT"):
for value in ("", "0", "-1", "1.0", "unknown"):
with self.subTest(field=field, value=value):
result, values, files = self.invoke("owner", **{field: value})
self.assertNotEqual(result.returncode, 0)
self.assertEqual((values, files), ({}, {}))


if __name__ == "__main__":
unittest.main()
Loading