diff --git a/.github/workflows/ctx-pipeline-receive.yml b/.github/workflows/ctx-pipeline-receive.yml index 208c7a7..1b2b76d 100644 --- a/.github/workflows/ctx-pipeline-receive.yml +++ b/.github/workflows/ctx-pipeline-receive.yml @@ -92,6 +92,59 @@ jobs: token: ${{ secrets.DOCS_READ_TOKEN }} path: .docs-src persist-credentials: false + # Full history: the monotonicity guard below needs ancestry. + fetch-depth: 0 + + # Reject out-of-order deliveries: an older dispatch arriving after a + # newer import must not roll skills back. The incoming commit must + # descend from the last imported one. Fail-closed semantics: only a + # genuinely absent prior state proceeds (bootstrap); a state that + # exists but cannot be validated FAILS the run — silence here is how + # rollbacks happen. + - name: Monotonicity guard + run: | + # True absence only — a directory, FIFO, or broken symlink at this + # path is NOT absence; it falls through and fails validation below. + if [ ! -e .ctx-gen/state.json ] && [ ! -L .ctx-gen/state.json ]; then + echo "no prior import state — bootstrap import proceeds" + exit 0 + fi + # Present but not a regular file (dir/FIFO/symlink): fail before + # node — require() on a dir can execute code, on a FIFO can hang. + if [ ! -f .ctx-gen/state.json ] || [ -L .ctx-gen/state.json ]; then + echo "::error::state.json exists but is not a regular file — failing closed" + exit 1 + fi + # Exactly one distinct recorded docsCommit, and it must look like a + # commit hash. Disagreement or garbage = corrupt state: fail, don't guess. + last=$(node -e ' + const s = require("./.ctx-gen/state.json"); + const entries = Object.values(s); + if (entries.length === 0) { console.error("state.json is present but empty — not a bootstrap; reset it explicitly if intended"); process.exit(1); } + // EVERY entry must carry the same valid commit — a partial state + // (some entries missing docsCommit) is corruption, not a quorum. + const commits = entries.map(v => v && v.docsCommit); + if (commits.some(c => !/^[0-9a-f]{40}$/.test(c || ""))) { console.error("state entry missing or malformed docsCommit — state is partial/corrupt"); process.exit(1); } + const distinct = [...new Set(commits)]; + if (distinct.length > 1) { console.error("state.json records conflicting docsCommit values: " + distinct.join(", ")); process.exit(1); } + console.log(distinct[0]); + ') || { echo "::error::import state exists but cannot be validated — failing closed (fix or explicitly reset .ctx-gen/state.json)"; exit 1; } + incoming=$(git -C .docs-src rev-parse HEAD) + if ! git -C .docs-src cat-file -e "$last" 2>/dev/null; then + echo "::error::last imported commit $last is not present in the docs checkout — cannot establish ordering (incomplete fetch or rewritten history); failing closed" + exit 1 + fi + rc=0 + git -C .docs-src merge-base --is-ancestor "$last" "$incoming" || rc=$? + if [ "$rc" = 1 ]; then + echo "::notice::stale delivery: incoming $incoming does not descend from last imported $last — skipping (a newer import already covers this)" + echo "SKIP_IMPORT=1" >> "$GITHUB_ENV" + elif [ "$rc" != 0 ]; then + echo "::error::merge-base failed with status $rc — cannot establish ordering; failing closed" + exit 1 + fi + + - name: Resolve docs commit id: docs @@ -99,6 +152,7 @@ jobs: - name: Import changed skills id: import + if: env.SKIP_IMPORT != '1' run: node scripts/ctx-receive.mjs --docs .docs-src --docs-commit "${{ steps.docs.outputs.sha }}" - name: Open or update the rolling sync PR