55 push :
66 branches : [main]
77 workflow_dispatch :
8+ # The `harvest` job's whole purpose is detecting that upstream shipped
9+ # different content, and that event produces no activity in this repository at
10+ # all -- so a gate firing only on push cannot detect a new upstream release,
11+ # which is the one case the drift requirement exists for. Weekly, off the hour.
12+ #
13+ # Known blind spot, and it cannot be closed from inside the repository: GitHub
14+ # disables scheduled workflows after prolonged repository inactivity, silently.
15+ # That is why the per-session tool-surface check on the operator's own machine
16+ # is the primary detector and this is the secondary net; see README.md.
17+ schedule :
18+ - cron : " 17 4 * * 1"
819 # Reused by `release.yml`, so a tag passes the same gate a pull request does
920 # rather than a copy of it that can drift.
1021 workflow_call :
1122
1223# Least privilege: nothing in this workflow writes to the repository.
24+ #
25+ # The scheduled `harvest` run inherits this and needs nothing more. Its failure
26+ # *is* the notification -- GitHub already delivers it -- so it opens no pull
27+ # request, pushes no branch, commits no regenerated artifact, and creates no
28+ # issue. Regenerating stays a reviewed human commit: buying that convenience
29+ # would cost write scopes on both contents and pull requests, and would make the
30+ # least-privilege posture depend on an automation nobody reviews per run.
1331permissions :
1432 contents : read
1533
@@ -187,21 +205,123 @@ jobs:
187205 timeout-minutes : 5
188206 run : bun run test:packaging
189207
190- # `test:packaging` rebuilds `dist/index.js` before loading it , so a green
191- # packaging step proves that a fresh bundle builds and registers what it
192- # claims -- never that the committed one matches the source beside it. The
193- # README tells an operator to load the committed file directly after
194- # `git clone` with no build step, so without this a pull request carrying
195- # an innocuous source diff and a substituted bundle would merge green.
196- - name : Verify the committed bundle matches the one built from source
208+ # `test:packaging` rebuilds both bundles before loading them , so a green
209+ # packaging step proves a fresh bundle builds and registers what it claims
210+ # -- never that the committed one matches the source beside it. The README
211+ # tells an operator to load the committed files directly after `git clone`
212+ # with no build step, so without this a pull request carrying an innocuous
213+ # source diff and a substituted bundle would merge green.
214+ - name : Verify the committed bundles match the ones built from source
197215 timeout-minutes : 5
198216 run : |
199217 set -euo pipefail
200218
201- # A pathspec matching nothing makes `git diff` exit 0, so the path is
219+ # Read out of the manifest rather than listed again here: the entries
220+ # OMP loads are the ones that have to match, and a second list drifts.
221+ # The feature entry is included, so declining the feature at install
222+ # time cannot make an unverified bundle ship.
223+ mapfile -t bundles < <(
224+ jq -r '(.omp.extensions // []) + ([.omp.features // {} | .[].extensions // []] | add // [])
225+ | .[] | sub("^\\./"; "")' package.json | sort -u
226+ )
227+
228+ if [ "${#bundles[@]}" -eq 0 ]; then
229+ echo "::error::package.json declares no extension entries; this check would verify nothing"
230+ exit 1
231+ fi
232+
233+ printf 'checking %d bundle(s)\n' "${#bundles[@]}"
234+ printf ' %s\n' "${bundles[@]}"
235+
236+ # A pathspec matching nothing makes `git diff` exit 0, so every path is
202237 # confirmed tracked before its diff is trusted.
203- git ls-files --error-unmatch dist/index.js
204- git diff --exit-code -- dist/index.js
238+ for bundle in "${bundles[@]}"; do
239+ git ls-files --error-unmatch "$bundle"
240+ done
241+ git diff --exit-code -- "${bundles[@]}"
242+
243+ # Runtime job. Regenerates the shipped skill, rule, and agents from a real CBM
244+ # executable and fails when the committed copies differ. The source of truth is
245+ # embedded in the executable and changes with it, so a copy nobody regenerates
246+ # becomes a second, silently diverging statement of the same contract.
247+ #
248+ # Also the secondary drift detector, which is why the workflow carries a
249+ # `schedule` trigger: see the comment on `on:` for what a push-only gate cannot
250+ # see, and README.md for why the per-session check is the primary one.
251+ harvest :
252+ name : harvest
253+ runs-on : ubuntu-24.04
254+ timeout-minutes : 30
255+ steps :
256+ - name : Check out the source tree
257+ uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
258+ timeout-minutes : 5
259+ with :
260+ persist-credentials : false
261+
262+ - name : Install the pinned Bun toolchain
263+ uses : oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
264+ timeout-minutes : 5
265+ with :
266+ bun-version : 1.3.14
267+
268+ - name : Install dependencies
269+ timeout-minutes : 5
270+ run : bun install --frozen-lockfile
271+
272+ - name : Acquire the newest CBM release through this package's own path
273+ timeout-minutes : 15
274+ # Not `curl … install.sh | bash`: that is an unpinned script run with the
275+ # runner's privileges. This goes through `src/acquire.ts`, which verifies
276+ # the asset against the release's own `checksums.txt`, refuses an archive
277+ # whose member list is not exactly the expected four, and runs the
278+ # candidate once before adopting it.
279+ run : bun run scripts/acquire-cbm.ts
280+
281+ - name : Regenerate the shipped context artifacts
282+ timeout-minutes : 10
283+ # `--stop-sessions` is a no-op here and load-bearing on a contributor's
284+ # machine: `install` is CBM's activation path and drains active CBM
285+ # sessions first, so the harvest refuses by default rather than closing
286+ # an editor's MCP connection as a side effect of regenerating docs. A
287+ # runner has no sessions to close.
288+ run : bun run harvest --stop-sessions
289+
290+ - name : Fail when a committed generated artifact differs
291+ timeout-minutes : 5
292+ run : |
293+ set -euo pipefail
294+
295+ # From the provenance record the pipeline just wrote, so the list is
296+ # the pipeline's own and cannot fall behind it.
297+ mapfile -t generated < <(jq -r '.generated[]' harvest.json | sort -u)
298+
299+ if [ "${#generated[@]}" -eq 0 ]; then
300+ echo "::error::harvest.json names no generated paths; this check would verify nothing"
301+ exit 1
302+ fi
303+
304+ printf 'checking %d generated path(s)\n' "${#generated[@]}"
305+ printf ' %s\n' "${generated[@]}"
306+
307+ for file in "${generated[@]}"; do
308+ git ls-files --error-unmatch "$file"
309+ done
310+
311+ if ! git diff --exit-code -- "${generated[@]}"; then
312+ echo "::error::the committed context artifacts differ from what this CBM release emits; run \`bun run harvest\` and commit the result"
313+ exit 1
314+ fi
315+
316+ # A path the pipeline stopped writing, or one it started writing, is
317+ # drift the diff above cannot see: the first is not in the new list and
318+ # the second is not yet tracked.
319+ strays="$(git status --porcelain --untracked-files=all -- skills rules agents harvest.json)"
320+ if [ -n "$strays" ]; then
321+ echo "::error::the owned directories hold changes outside the regenerated set:"
322+ echo "$strays"
323+ exit 1
324+ fi
205325
206326 # Runtime job. Runs the two install commands the README documents, through
207327 # OMP's own CLI, so the documented path and the verified path are one path.
@@ -218,6 +338,10 @@ jobs:
218338 # release, so the documented command is verified against the exact ref an
219339 # operator can install. It is deliberately absent from `ci`'s `needs`: a
220340 # skipped job would otherwise fail the required check on every pull request.
341+ #
342+ # `schedule` is deliberately absent from the condition too. A scheduled run
343+ # exists to re-run the drift gate, it reports no pull-request check, and
344+ # installing by ref on a timer would verify the same ref again for nothing.
221345 install-check :
222346 name : install check
223347 if : github.event_name == 'workflow_dispatch' || github.event_name == 'push'
@@ -292,6 +416,28 @@ jobs:
292416 cat "$HOME/plugins.txt"
293417 grep -q 'omp-codebase-memory' "$HOME/plugins.txt"
294418
419+ # A packaging change can drop a whole directory without changing a
420+ # single file in it, so the shipped context surfaces are asserted on
421+ # the *installed* tree rather than on the working tree the suite reads.
422+ # The root comes from OMP's own registry, not from a path guessed here.
423+ root="$(omp plugin list --json | jq -r '.npm[] | select(.name == "omp-codebase-memory") | .path')"
424+ if [ -z "$root" ] || [ ! -d "$root" ]; then
425+ echo "::error::omp plugin list --json reported no installed path for omp-codebase-memory"
426+ exit 1
427+ fi
428+ echo "installed at $root"
429+
430+ missing=0
431+ while read -r file; do
432+ if [ -f "$root/$file" ]; then
433+ echo " ok $file"
434+ else
435+ echo "::error::the installed tree is missing $file"
436+ missing=$((missing + 1))
437+ fi
438+ done <<< "$(jq -r '.generated[]' harvest.json)"
439+ [ "$missing" -eq 0 ]
440+
295441 - name : Link this checkout the way the development install documents
296442 timeout-minutes : 5
297443 run : |
@@ -317,7 +463,7 @@ jobs:
317463 # means editing this job's `needs` and nothing else.
318464 ci :
319465 name : ci
320- needs : [hygiene, bun]
466+ needs : [hygiene, bun, harvest ]
321467 # `always()` is load-bearing. Without it this job is skipped when a
322468 # dependency fails, and a skipped required check blocks a pull request
323469 # rather than failing it -- a stuck merge button instead of a red one.
0 commit comments