Files
archy/scripts/check-manifest-shell.py
T
archipelagoandClaude Fable 5 e88c51d80b fix(bitcoin): repair the startup script I broke in 1.7.124, and gate against it
The bitcoin app vanished from updated nodes: the container exited instantly
with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an
explanatory comment INSIDE the manifest's folded YAML scalar (>-), where
'#' is not a comment — it is literal text that reaches the shell. Folding
joins lines with spaces, so the comment swallowed the 'if ... then' while
the more-indented echo survived as its own line, leaving an orphan 'fi'.
bitcoind never ran, the container exited, and the app disappeared from the
UI because detection is container-based.

Explanations now live above the '- >-' line where YAML really treats them
as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged
and still needed.

Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n'
over every embedded manifest script and rejects '#' inside these scalars.
Nothing validated this shell before — no YAML parse or Rust test could have
caught it, and it only failed on the node, after signing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:45:02 -04:00

87 lines
3.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Syntax-check the shell embedded in apps/*/manifest.yml.
A manifest can carry a whole startup script in `container.custom_args` /
`entrypoint`. Nothing validated it, so a broken one shipped through the
signed catalog and only failed on the node — as a container that exits
instantly and an app that vanishes from the UI.
Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core):
1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`,
which no YAML parse and no Rust test could have caught.
2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`),
where `#` is NOT a comment — it is literal text that reaches the shell,
and because folding joins lines with spaces it comments out the rest of
the folded line. That is exactly how an `if ... then` was swallowed while
its more-indented body survived, leaving an orphan `fi`. Put explanations
above the `- >-` line, where YAML really does treat them as comments.
"""
from __future__ import annotations
import glob
import os
import subprocess
import sys
import tempfile
import yaml
# Long enough to be a script rather than a flag.
MIN_SCRIPT_LEN = 60
def snippets(path: str):
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
container = ((data or {}).get("app") or {}).get("container") or {}
for key in ("custom_args", "entrypoint"):
value = container.get(key)
if not isinstance(value, list):
continue
for i, part in enumerate(value):
if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN:
yield f"{key}[{i}]", part
def main() -> int:
failures = []
checked = 0
for path in sorted(glob.glob("apps/*/manifest.yml")):
app = os.path.basename(os.path.dirname(path))
try:
found = list(snippets(path))
except Exception as exc: # noqa: BLE001 — report, don't crash the gate
failures.append(f"{app}: manifest does not parse: {exc}")
continue
for where, script in found:
checked += 1
if "#" in script:
failures.append(
f"{app} {where}: contains '#'. In a folded YAML scalar that is not a "
f"comment — it reaches the shell and comments out the rest of the "
f"folded line. Move the explanation above the '- >-' line."
)
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp:
tmp.write(script)
tmp_path = tmp.name
try:
proc = subprocess.run(
["sh", "-n", tmp_path], capture_output=True, text=True, check=False
)
finally:
os.unlink(tmp_path)
if proc.returncode != 0:
failures.append(f"{app} {where}: {proc.stderr.strip()}")
for f in failures:
print(f"MANIFEST-SHELL {f}", file=sys.stderr)
print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}')
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())