Skip to content

Commit c7ed328

Browse files
committed
fix: make payload drift checks read-only
what: - separate read-only CI drift detection from normal payload synchronization - cover canonical reference changes in CI and add a regression test - document the hybrid source-ownership boundary why: - the previous --ci path overwrote drift before reporting success, allowing stale payloads to pass
1 parent 499e0a7 commit c7ed328

4 files changed

Lines changed: 160 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- "README.md"
1010
- "CITATION.cff"
1111
- "SECURITY.md"
12+
- "references/**"
1213
- "scripts/**"
1314
- "skills/**"
1415
pull_request:
@@ -19,6 +20,7 @@ on:
1920
- "README.md"
2021
- "CITATION.cff"
2122
- "SECURITY.md"
23+
- "references/**"
2224
- "scripts/**"
2325
- "skills/**"
2426
schedule:
@@ -59,8 +61,11 @@ jobs:
5961
- name: Verify referenced URLs
6062
run: python3 scripts/verify-urls.py
6163

64+
- name: Test payload sync behavior
65+
run: python3 scripts/test-sync-payload.py
66+
6267
- name: Payload sync check
6368
run: bash scripts/sync-payload.sh --ci
6469

6570
- name: Ruff check scripts
66-
run: python3 -m ruff check scripts
71+
run: python3 -m ruff check scripts

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ references in any shipped skill file or reference.
120120
python3 .github/scripts/check-portability.py
121121
python3 scripts/validate.py
122122
python3 scripts/verify-urls.py
123+
python3 scripts/test-sync-payload.py
124+
bash scripts/sync-payload.sh --ci
123125
python3 -m ruff check scripts
124126
```
125127

@@ -139,6 +141,9 @@ python-project-workflow/
139141
│ ├── workflows/ci.yml
140142
│ └── scripts/check-portability.py
141143
├── scripts/
144+
│ ├── payload-manifest.json
145+
│ ├── sync-payload.sh
146+
│ ├── test-sync-payload.py
142147
│ ├── validate.py
143148
│ └── verify-urls.py
144149
└── skills/
@@ -150,11 +155,15 @@ python-project-workflow/
150155
├── core-footguns.md
151156
├── safe-editing.md
152157
├── mature-repo-preservation.md
153-
└── eval-benchmark-hardening.md
158+
├── eval-benchmark-hardening.md
159+
└── drift-classes.md
154160
```
155161

156-
Shipping boundary: `skills/python-project-workflow/` is the runtime payload and
157-
source of truth. Everything else is repository-only development infrastructure.
162+
Shipping boundary: `skills/python-project-workflow/` is the runtime payload.
163+
Its `SKILL.md` is authored in place, while root `references/` is the canonical
164+
source for mirrored payload references. `scripts/payload-manifest.json` declares
165+
what is mirrored, and `scripts/sync-payload.sh --ci` verifies the boundary without
166+
modifying it. Everything else is repository-only development infrastructure.
158167

159168
## References
160169

@@ -166,6 +175,7 @@ source of truth. Everything else is repository-only development infrastructure.
166175
| `safe-editing.md` | Safe edit workflow for backslash-heavy content |
167176
| `mature-repo-preservation.md` | Preservation-first workflow for established repos |
168177
| `eval-benchmark-hardening.md` | Benchmark and eval hardening guidance |
178+
| `drift-classes.md` | Payload drift and installed-mirror staleness detection and remediation |
169179

170180
The Orientation Checklist is now inlined in SKILL.md § Orientation Checklist.
171181

scripts/sync-payload.sh

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,36 @@ CI_MODE=false
1414

1515
echo "Syncing skill payload..."
1616
DRIFT=false
17+
CHANGED=false
18+
SYNC_ERROR=false
19+
20+
sync_file() {
21+
local source="$1"
22+
local target="$2"
23+
local label="$3"
24+
local mode="${4:-644}"
25+
26+
if [ ! -f "$source" ]; then
27+
echo " MISSING source: $label"
28+
DRIFT=true
29+
SYNC_ERROR=true
30+
return
31+
fi
32+
33+
if $CI_MODE; then
34+
if [ ! -f "$target" ] || ! cmp -s "$source" "$target"; then
35+
echo " DRIFTED: $label"
36+
DRIFT=true
37+
fi
38+
return
39+
fi
40+
41+
if [ ! -f "$target" ] || ! cmp -s "$source" "$target"; then
42+
CHANGED=true
43+
fi
44+
mkdir -p "$(dirname "$target")"
45+
install -m "$mode" "$source" "$target"
46+
}
1747

1848
is_covered() {
1949
local rel="$1"
@@ -30,7 +60,9 @@ is_covered() {
3060
local ref_mode
3161
ref_mode=$(python3 -c "import json; d=json.load(open('$MANIFEST')); r=d.get('references',''); print(r if isinstance(r,str) else '')" 2>/dev/null)
3262
if [ "$ref_mode" = "*" ]; then
33-
case "$rel" in references/*) return 0 ;; esac
63+
case "$rel" in
64+
references/*) [ -f "$ROOT/$rel" ] && return 0 ;;
65+
esac
3466
fi
3567
return 1
3668
}
@@ -39,47 +71,62 @@ is_covered() {
3971
for f in $(python3 -c "import json; d=json.load(open('$MANIFEST')); print('\n'.join(str(x) for x in d.get('files',[])))" 2>/dev/null); do
4072
source="$ROOT/$f"
4173
target="$PAYLOAD_DIR/$f"
42-
if [ ! -f "$source" ]; then echo " MISSING source: $f"; DRIFT=true; continue; fi
43-
mkdir -p "$(dirname "$target")"
44-
install -m 644 "$source" "$target"
74+
sync_file "$source" "$target" "$f"
4575
done
4676

4777
# Scripts
4878
for s in $(python3 -c "import json; d=json.load(open('$MANIFEST')); print('\n'.join(str(x) for x in d.get('scripts',[])))" 2>/dev/null); do
4979
source="$ROOT/scripts/$s"
5080
target="$PAYLOAD_DIR/scripts/$s"
51-
if [ ! -f "$source" ]; then echo " MISSING source: scripts/$s"; DRIFT=true; continue; fi
52-
mkdir -p "$(dirname "$target")"
53-
if [ -x "$source" ]; then install -m 755 "$source" "$target"; else install -m 644 "$source" "$target"; fi
81+
mode=644
82+
[ -x "$source" ] && mode=755
83+
sync_file "$source" "$target" "scripts/$s" "$mode"
5484
done
5585

5686
# References (mirror)
5787
ref_mode=$(python3 -c "import json; d=json.load(open('$MANIFEST')); r=d.get('references',''); print(r if isinstance(r,str) else '')" 2>/dev/null)
5888
if [ "$ref_mode" = "*" ]; then
59-
mkdir -p "$PAYLOAD_DIR/references"
60-
find "$PAYLOAD_DIR/references" -type f -delete 2>/dev/null || true
61-
cp "$ROOT/references/"*.md "$PAYLOAD_DIR/references/"
89+
for source in "$ROOT/references/"*.md; do
90+
if [ ! -f "$source" ]; then
91+
echo " MISSING source: references/*.md"
92+
DRIFT=true
93+
SYNC_ERROR=true
94+
break
95+
fi
96+
name="$(basename "$source")"
97+
sync_file "$source" "$PAYLOAD_DIR/references/$name" "references/$name"
98+
done
6299
fi
63100

64101
# Orphan cleanup
65-
orphans=0
66102
while IFS= read -r -d '' f; do
67103
relpath="$f"
68104
# shellcheck disable=SC2295
69105
rel="${relpath#$PAYLOAD_DIR/}"
70106
if ! is_covered "$rel"; then
71107
echo " ORPHANED: $rel"
72-
rm -f "$f"
73-
orphans=$((orphans + 1))
108+
if ! $CI_MODE; then
109+
rm -f "$f"
110+
CHANGED=true
111+
else
112+
DRIFT=true
113+
fi
74114
fi
75115
done < <(find "$PAYLOAD_DIR" -type f -print0)
76-
find "$PAYLOAD_DIR" -type d -empty -delete 2>/dev/null || true
116+
if ! $CI_MODE; then
117+
find "$PAYLOAD_DIR" -type d -empty -delete 2>/dev/null || true
118+
fi
77119

78-
[ "$orphans" -gt 0 ] && DRIFT=true
79-
if $DRIFT; then
120+
if $SYNC_ERROR; then
121+
echo ""
122+
echo "SYNC FAILED"
123+
exit 1
124+
elif $DRIFT; then
80125
echo ""
81126
echo "DRIFT DETECTED"
82-
$CI_MODE && exit 1
127+
exit 1
128+
elif $CHANGED; then
129+
echo "Payload synchronized"
83130
else
84131
echo "Payload in sync"
85-
fi
132+
fi

scripts/test-sync-payload.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python3
2+
"""Regression tests for read-only payload drift detection."""
3+
4+
from __future__ import annotations
5+
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
from pathlib import Path
10+
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
REFERENCE = Path("skills/python-project-workflow/references/core-footguns.md")
14+
ORPHAN = Path("skills/python-project-workflow/references/orphan.md")
15+
16+
17+
def run_sync(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
18+
return subprocess.run(
19+
["bash", "scripts/sync-payload.sh", *args],
20+
cwd=root,
21+
text=True,
22+
stdout=subprocess.PIPE,
23+
stderr=subprocess.STDOUT,
24+
check=False,
25+
)
26+
27+
28+
def main() -> int:
29+
with tempfile.TemporaryDirectory(prefix="python-project-workflow-sync-") as tmp:
30+
fixture = Path(tmp) / "repo"
31+
shutil.copytree(
32+
ROOT,
33+
fixture,
34+
ignore=shutil.ignore_patterns(".git", ".ruff_cache", "__pycache__"),
35+
)
36+
37+
target = fixture / REFERENCE
38+
target.write_text(
39+
target.read_text(encoding="utf-8") + "\nintentional drift\n",
40+
encoding="utf-8",
41+
)
42+
orphan = fixture / ORPHAN
43+
orphan.write_text("orphan\n", encoding="utf-8")
44+
before = target.read_bytes()
45+
46+
check = run_sync(fixture, "--ci")
47+
if check.returncode == 0:
48+
raise AssertionError(f"--ci accepted payload drift:\n{check.stdout}")
49+
if target.read_bytes() != before:
50+
raise AssertionError("--ci modified a drifted payload file")
51+
if not orphan.exists():
52+
raise AssertionError("--ci removed an orphaned payload file")
53+
if "DRIFTED: references/core-footguns.md" not in check.stdout:
54+
raise AssertionError(f"--ci did not report content drift:\n{check.stdout}")
55+
if "ORPHANED: references/orphan.md" not in check.stdout:
56+
raise AssertionError(f"--ci did not report the orphan:\n{check.stdout}")
57+
58+
sync = run_sync(fixture)
59+
if sync.returncode != 0:
60+
raise AssertionError(f"normal sync failed:\n{sync.stdout}")
61+
source = fixture / "references/core-footguns.md"
62+
if target.read_bytes() != source.read_bytes():
63+
raise AssertionError("normal sync did not repair payload drift")
64+
if orphan.exists():
65+
raise AssertionError("normal sync did not remove the orphan")
66+
67+
final_check = run_sync(fixture, "--ci")
68+
if final_check.returncode != 0:
69+
raise AssertionError(f"repaired payload failed --ci:\n{final_check.stdout}")
70+
71+
print("PASS: payload CI mode detects drift without modifying files")
72+
return 0
73+
74+
75+
if __name__ == "__main__":
76+
raise SystemExit(main())

0 commit comments

Comments
 (0)