openvibers/openvibe.shared Commits

Pin drift (WS-C task 5): scripts/pin-drift.js fails on an openvibe-contracts/-shared/-sdk/-publishing pin that is not a published tag or is more than one minor (or a major) behind the latest release; the reusable test workflow runs it from main over the whole repository (pin-drift: false skips); custom CIs run the same curl line

OP OpenVibers committed 25 Sept 2026 · 423fe519b5 · 3 files, +118 −0


Changes

ddiff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
iindex 97de63c..7af03ac 100644
---- a/.github/workflows/test.yml
++++ b/.github/workflows/test.yml
@@@ -20,6 +20,9 @@ name: test
# contracts openvibe-contracts-check --service <contracts-service> --src <dir> for each directory
# in contracts-src, when contracts-service is set: the code's capability and event use
# against the release
++# pin drift scripts/pin-drift.js from OpenVibe.Shared main over the whole repository: a pin of
++# openvibe-contracts/-shared/-sdk/-publishing that is not a published tag, or more than one
++# minor behind the latest release, fails (roadmap WS-C task 5; `pin-drift: false` skips)
# extra `extra`, when set: any further commands, one per line (e.g. a budget check)
on:
workflow_call:
@@@ -60,6 +63,10 @@ on:
description: Further commands after the tests, one per line ("" skips).
type: string
default: ''
++ pin-drift:
++ description: Fail on drifted openvibe-* library pins (OpenVibe.Shared scripts/pin-drift.js).
++ type: boolean
++ default: true
permissions:
contents: read
@@@ -109,6 +116,10 @@ jobs:
run: |
args=(); for d in $SRC; do args+=(--src "$d"); done
npx openvibe-contracts-check --service "$SERVICE" "${args[@]}"
++ - name: pin drift
++ if: ${{ inputs.pin-drift }}
++ working-directory: .
++ run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
- name: extra
if: ${{ inputs.extra != '' }}
run: ${{ inputs.extra }}
ddiff --git a/scripts/pin-drift.js b/scripts/pin-drift.js
nnew file mode 100644
iindex 0000000..a3a8c50
---- /dev/null
++++ b/scripts/pin-drift.js
@@@ -0,0 +1,82 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Pin drift (roadmap WS-C task 5): every OpenVibers repository pins the shared libraries by release tag
++ * (`https://codeload.github.com/OpenVibers/<repo>/tar.gz/refs/tags/vX.Y.Z`). CI fails when a pin
++ *
++ * - names a tag that is not published in that repository (an unpublished pin), or
++ * - is older than the latest release by more than one minor (0.x: 0.40 fails when 0.42 is out; a major
++ * behind always fails).
++ *
++ * node scripts/pin-drift.js [dir] every package.json under dir (git-tracked), default .
++ * curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - [dir]
++ *
++ * Checks openvibe-contracts, openvibe-shared, openvibe-sdk and openvibe-publishing in dependencies,
++ * devDependencies, optionalDependencies and peerDependencies; ranges that are not tag URLs (a peer range
++ * like ">=1.5.0") are not pins and are skipped. Tags come from `git ls-remote --tags` (no token needed).
++ * Exit 0 = no drift, 1 = drift, 2 = could not check (network).
++ */
++const fs = require('fs');
++const path = require('path');
++const cp = require('child_process');
++
++const LIBS = { 'openvibe-contracts': 'OpenVibe.Contracts', 'openvibe-shared': 'OpenVibe.Shared', 'openvibe-sdk': 'OpenVibe.SDK', 'openvibe-publishing': 'OpenVibe.Publishing' };
++const PIN = /OpenVibers\/(OpenVibe\.[A-Za-z]+)\/tar\.gz\/refs\/tags\/v(\d+)\.(\d+)\.(\d+)$/;
++
++const parse = (t) => { const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(t); return m ? m.slice(1).map(Number) : null; };
++const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
++
++/** A pin's verdict against the published tags. → { ok, reason } */
++function judge(pin, published) {
++ const tags = published.map(parse).filter(Boolean).sort(cmp);
++ if (!tags.length) return { ok: false, reason: 'no published tags' };
++ if (!tags.some((t) => cmp(t, pin) === 0)) return { ok: false, reason: `v${pin.join('.')} is not a published tag` };
++ const latest = tags[tags.length - 1];
++ if (pin[0] < latest[0]) return { ok: false, reason: `v${pin.join('.')} is a major behind v${latest.join('.')}` };
++ if (pin[0] === latest[0] && latest[1] - pin[1] > 1) return { ok: false, reason: `v${pin.join('.')} is ${latest[1] - pin[1]} minors behind v${latest.join('.')}` };
++ return { ok: true, reason: `latest v${latest.join('.')}` };
++}
++
++function packageFiles(dir) {
++ try {
++ return cp.execSync("git ls-files -- '*package.json' 'package.json'", { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter((f) => f && !f.includes('node_modules/')).map((f) => path.join(dir, f));
++ } catch { return fs.existsSync(path.join(dir, 'package.json')) ? [path.join(dir, 'package.json')] : []; }
++}
++
++function pinsIn(file) {
++ const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
++ const out = [];
++ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
++ for (const [name, spec] of Object.entries(pkg[field] || {})) {
++ if (!LIBS[name]) continue;
++ const m = PIN.exec(String(spec));
++ if (m && m[1] === LIBS[name]) out.push({ file, field, name, repo: m[1], pin: [Number(m[2]), Number(m[3]), Number(m[4])] });
++ }
++ }
++ return out;
++}
++
++function main(dir = '.', { tagsOf } = {}) {
++ const cache = new Map();
++ const tags = tagsOf || ((repo) => {
++ if (!cache.has(repo)) {
++ const raw = cp.execSync(`git ls-remote --tags https://github.com/OpenVibers/${repo}.git`, { encoding: 'utf8', timeout: 30000 });
++ cache.set(repo, [...new Set(raw.split('\n').map((l) => (l.split('refs/tags/')[1] || '').replace(/\^\{\}$/, '')).filter(Boolean))]);
++ }
++ return cache.get(repo);
++ });
++ const results = [];
++ for (const file of packageFiles(dir)) for (const p of pinsIn(file)) results.push({ ...p, ...judge(p.pin, tags(p.repo)) });
++ return results;
++}
++
++module.exports = { judge, pinsIn, main, LIBS };
++
++if (require.main === module || !module.parent) {
++ let results;
++ try { results = main(process.argv[2] || '.'); } catch (err) { console.error(`pin-drift: could not read tags (${err.message})`); process.exit(2); }
++ for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${path.relative(process.cwd(), r.file) || r.file} ${r.name} v${r.pin.join('.')}: ${r.reason}`);
++ const bad = results.filter((r) => !r.ok);
++ console.log(bad.length ? `pin-drift: ${bad.length} of ${results.length} pin(s) drifted` : `pin-drift: ${results.length} pin(s), no drift`);
++ process.exit(bad.length ? 1 : 0);
++}
ddiff --git a/test/pin-drift.test.js b/test/pin-drift.test.js
nnew file mode 100644
iindex 0000000..0dc38cd
---- /dev/null
++++ b/test/pin-drift.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++// scripts/pin-drift.js (WS-C task 5): unpublished pins and pins more than one minor (or a major) behind fail.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { judge, main } = require('../scripts/pin-drift');
++
++const tags = ['v0.40.0', 'v0.41.0', 'v0.41.1', 'v0.42.0'];
++assert.strictEqual(judge([0, 42, 0], tags).ok, true);
++assert.strictEqual(judge([0, 41, 1], tags).ok, true, 'one minor behind is allowed');
++assert.match(judge([0, 40, 0], tags).reason, /2 minors behind v0\.42\.0/);
++assert.match(judge([0, 42, 1], tags).reason, /not a published tag/);
++assert.match(judge([1, 12, 0], ['v1.12.0', 'v2.0.0']).reason, /major behind/);
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-drift-'));
++const url = (repo, v) => `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${v}`;
++fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
++ dependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.39.0'), 'openvibe-sdk': url('OpenVibe.SDK', 'v0.9.1'), express: '^4.0.0' },
++ peerDependencies: { 'openvibe-shared': '>=1.5.0' },
++}));
++const out = main(dir, { tagsOf: (repo) => (repo === 'OpenVibe.Contracts' ? tags : ['v0.9.0', 'v0.9.1']) });
++assert.deepStrictEqual(out.map((r) => [r.name, r.ok]), [['openvibe-contracts', false], ['openvibe-sdk', true]], 'ranges are not pins; other packages are ignored');
++fs.rmSync(dir, { recursive: true, force: true });
++console.log('pin-drift: all checks passed');