openvibers/openvibe.shared Commits

pin-drift: a pin more than one minor behind fails only once the release that makes it so is over 14 days old (dates from the library's CHANGELOG.md; undated releases count as old), so CI stays green on the day a library ships and still catches pins left behind

OP OpenVibers committed 25 Sept 2026 · b1e2677245 · 2 files, +42 −11


Changes

ddiff --git a/scripts/pin-drift.js b/scripts/pin-drift.js
iindex a3a8c50..31245e3 100644
---- a/scripts/pin-drift.js
++++ b/scripts/pin-drift.js
@@@ -5,8 +5,11 @@
* (`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).
++ * - is a major behind, or
++ * - is more than one minor behind a release that has been out for more than GRACE_DAYS (14): 0.40 fails
++ * once 0.42 is two weeks old. The grace keeps CI green on the day a library ships and still catches
++ * pins left behind. Release dates come from the library's CHANGELOG.md on main ("## 0.42.0 — 2026-09-25"
++ * or "## 0.9.1 (2026-09-24)"); a release with no date there counts as old.
*
* 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]
@@@ -23,17 +26,31 @@ 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 GRACE_DAYS = 14;
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) {
++/** "1.2.3" → release date (ms) from a CHANGELOG.md. */
++function releaseDates(changelog) {
++ const out = new Map();
++ for (const m of String(changelog || '').matchAll(/^##\s*\[?v?(\d+\.\d+\.\d+)\]?\s*(?:—|-|–|\()\s*(\d{4}-\d{2}-\d{2})/gm)) out.set(m[1], Date.parse(`${m[2]}T00:00:00Z`));
++ return out;
++}
++
++/** A pin's verdict against the published tags (and their release dates). → { ok, reason } */
++function judge(pin, published, { dates = new Map(), now = Date.now(), graceDays = GRACE_DAYS } = {}) {
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('.')}` };
++ // The releases two or more minors ahead of the pin that have been out longer than the grace period.
++ const old = tags.filter((t) => t[0] === pin[0] && t[1] - pin[1] > 1).filter((t) => {
++ const at = dates.get(t.join('.'));
++ return !(Number.isFinite(at) && now - at <= graceDays * 86400000);
++ });
++ if (old.length) return { ok: false, reason: `v${pin.join('.')} is ${latest[1] - pin[1]} minors behind v${latest.join('.')} (v${old[old.length - 1].join('.')} is over ${graceDays} days old)` };
++ if (latest[1] - pin[1] > 1) return { ok: true, reason: `latest v${latest.join('.')}; behind by ${latest[1] - pin[1]} minors, within the ${graceDays}-day grace` };
return { ok: true, reason: `latest v${latest.join('.')}` };
}
@@@ -56,8 +73,17 @@ function pinsIn(file) {
return out;
}
--function main(dir = '.', { tagsOf } = {}) {
++function main(dir = '.', { tagsOf, datesOf, now = Date.now() } = {}) {
const cache = new Map();
++ const dateCache = new Map();
++ const dates = datesOf || ((repo) => {
++ if (!dateCache.has(repo)) {
++ let text = '';
++ try { text = cp.execSync(`curl -fsSL --max-time 20 https://raw.githubusercontent.com/OpenVibers/${repo}/main/CHANGELOG.md`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* no dates: every release counts as old */ }
++ dateCache.set(repo, releaseDates(text));
++ }
++ return dateCache.get(repo);
++ });
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 });
@@@ -66,11 +92,11 @@ function main(dir = '.', { tagsOf } = {}) {
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)) });
++ for (const file of packageFiles(dir)) for (const p of pinsIn(file)) results.push({ ...p, ...judge(p.pin, tags(p.repo), { dates: dates(p.repo), now }) });
return results;
}
--module.exports = { judge, pinsIn, main, LIBS };
++module.exports = { judge, pinsIn, main, releaseDates, LIBS, GRACE_DAYS };
if (require.main === module || !module.parent) {
let results;
ddiff --git a/test/pin-drift.test.js b/test/pin-drift.test.js
iindex 0dc38cd..734b0c8 100644
---- a/test/pin-drift.test.js
++++ b/test/pin-drift.test.js
@@@ -4,12 +4,17 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
--const { judge, main } = require('../scripts/pin-drift');
++const { judge, main, releaseDates } = 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, 40, 0], tags).reason, /2 minors behind v0\.42\.0/, 'without dates every release counts as old');
++const DAY = 86400000, now = Date.parse('2026-10-20T00:00:00Z');
++const dates = releaseDates('## 0.42.0 — 2026-10-15\n...\n## 0.41.1 (2026-09-30)\n## [0.41.0] - 2026-09-20\n');
++assert.strictEqual(dates.get('0.42.0'), Date.parse('2026-10-15T00:00:00Z')); assert.strictEqual(dates.get('0.41.0'), Date.parse('2026-09-20T00:00:00Z'));
++assert.strictEqual(judge([0, 40, 0], tags, { dates, now }).ok, true, 'v0.42.0 is 5 days old: within the grace');
++assert.strictEqual(judge([0, 40, 0], tags, { dates, now: now + 10 * DAY }).ok, false, 'v0.42.0 is 15 days old: drifted');
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/);
@@@ -19,7 +24,7 @@ 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']) });
++const out = main(dir, { tagsOf: (repo) => (repo === 'OpenVibe.Contracts' ? tags : ['v0.9.0', 'v0.9.1']), datesOf: () => new Map() });
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');