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
Changes
@@@ -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;@@@ -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');
@@ -5,8 +5,11 @@
@@ -5,8 +5,11 @@
* (`https://codeload.github.com/OpenVibers/<repo>/tar.gz/refs/tags/vX.Y.Z`). CI fails when a pin
* (`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
* - 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 .
* 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]
* 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');
@@ -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 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 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 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];
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);
const tags = published.map(parse).filter(Boolean).sort(cmp);
if (!tags.length) return { ok: false, reason: 'no published tags' };
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` };
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];
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]) 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('.')}` };
return { ok: true, reason: `latest v${latest.join('.')}` };
}
}
@@ -56,8 +73,17 @@ function pinsIn(file) {
@@ -56,8 +73,17 @@ function pinsIn(file) {
return out;
return out;
}
}
-function main(dir = '.', { tagsOf } = {}) {
+function main(dir = '.', { tagsOf, datesOf, now = Date.now() } = {}) {
const cache = new Map();
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) => {
const tags = tagsOf || ((repo) => {
if (!cache.has(repo)) {
if (!cache.has(repo)) {
const raw = cp.execSync(`git ls-remote --tags https://github.com/OpenVibers/${repo}.git`, { encoding: 'utf8', timeout: 30000 });
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 } = {}) {
@@ -66,11 +92,11 @@ function main(dir = '.', { tagsOf } = {}) {
return cache.get(repo);
return cache.get(repo);
});
});
const results = [];
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;
return results;
}
}
-module.exports = { judge, pinsIn, main, LIBS };
+module.exports = { judge, pinsIn, main, releaseDates, LIBS, GRACE_DAYS };
if (require.main === module || !module.parent) {
if (require.main === module || !module.parent) {
let results;
let results;
@@ -4,12 +4,17 @@ const assert = require('assert');
@@ -4,12 +4,17 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
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'];
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, 42, 0], tags).ok, true);
assert.strictEqual(judge([0, 41, 1], tags).ok, true, 'one minor behind is allowed');
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([0, 42, 1], tags).reason, /not a published tag/);
assert.match(judge([1, 12, 0], ['v1.12.0', 'v2.0.0']).reason, /major behind/);
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({
@@ -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' },
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' },
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');
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 });
fs.rmSync(dir, { recursive: true, force: true });
console.log('pin-drift: all checks passed');
console.log('pin-drift: all checks passed');