Skip to content

Commit c0371e1

Browse files
committed
feat: add process docs and scripts for yarn.lock only backports
Signed-off-by: Jessica He <jhe@redhat.com>
1 parent c5d9f95 commit c0371e1

7 files changed

Lines changed: 1132 additions & 8 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# yarnlock-backport
2+
3+
Generate `0-cve-yarn-lock.patch` and `cve-backports.yaml` for overlay workspaces without bumping `source.json:repo-ref`.
4+
5+
Full workflow: [user-guide/06-patch-management.md](../../user-guide/06-patch-management.md).
6+
7+
```bash
8+
cd scripts/yarnlock-backport && npm install
9+
10+
export OVERLAY_WORKSPACE=<absolute-path>/workspaces/orchestrator
11+
export PLUGINS_REPO=<absolute-path>
12+
13+
yarnlock-backport prepare --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO"
14+
# … yarn up in plugins workspace …
15+
yarnlock-backport generate --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO" --cve 'CVE-…,CVE-…'
16+
```
17+
18+
Requires: Node.js, `git`, `yarn`, `patch`, `diff`, `npm`. `--overlay-workspace` and `--plugins-repo` must be **absolute** paths.
19+
20+
Fork clones need an `upstream` remote pointing at `https://github.com/redhat-developer/rhdh-plugin-export-overlays.git` (step 0 syncs the release branch from there).
21+
22+
```bash
23+
npm test
24+
```
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import {
5+
assertPathWithin,
6+
normalizeCveId,
7+
parseCveArg,
8+
parseCveCliToken,
9+
parseCveDetails,
10+
releaseBranch,
11+
requireLockfileChange,
12+
resolveCliPath,
13+
resolveOverlayGitRemote,
14+
resolvePluginsGitRemote,
15+
validateGitRef,
16+
validateReleaseBranch,
17+
validateWorkspaceName,
18+
} from './backport.ts';
19+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
20+
import { tmpdir } from 'node:os';
21+
import { join, resolve } from 'node:path';
22+
23+
describe('requireLockfileChange', () => {
24+
it('accepts when baseline and patched differ', () => {
25+
assert.doesNotThrow(() => requireLockfileChange('a', 'b'));
26+
});
27+
28+
it('rejects when lockfile is unchanged from repo-ref', () => {
29+
assert.throws(() => requireLockfileChange('same', 'same'), /yarn.lock unchanged from repo-ref baseline/);
30+
});
31+
});
32+
33+
describe('resolveOverlayGitRemote', () => {
34+
const forkRemotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch)
35+
origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (push)
36+
upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch)
37+
upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (push)`;
38+
39+
it('prefers upstream on fork clones', () => {
40+
assert.equal(
41+
resolveOverlayGitRemote(forkRemotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'),
42+
'upstream',
43+
);
44+
});
45+
46+
it('uses origin for direct upstream clones', () => {
47+
const remotes = `origin\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch)`;
48+
assert.equal(
49+
resolveOverlayGitRemote(remotes, 'https://github.com/redhat-developer/rhdh-plugin-export-overlays.git'),
50+
'origin',
51+
);
52+
});
53+
54+
it('errors when fork has no upstream remote', () => {
55+
const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch)`;
56+
assert.throws(
57+
() => resolveOverlayGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'),
58+
/no upstream remote/,
59+
);
60+
});
61+
});
62+
63+
describe('resolvePluginsGitRemote', () => {
64+
it('prefers upstream on fork clones', () => {
65+
const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugins.git (fetch)
66+
upstream\thttps://github.com/redhat-developer/rhdh-plugins.git (fetch)`;
67+
assert.equal(resolvePluginsGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugins.git'), 'upstream');
68+
});
69+
});
70+
71+
describe('releaseBranch', () => {
72+
it('maps release version to branch name', () => {
73+
assert.equal(releaseBranch('1.10'), 'release-1.10');
74+
});
75+
76+
it('accepts branch name as-is', () => {
77+
assert.equal(releaseBranch('release-1.10'), 'release-1.10');
78+
});
79+
80+
it('rejects empty release', () => {
81+
assert.throws(() => releaseBranch(' '), /release is empty/);
82+
});
83+
});
84+
85+
describe('validateReleaseBranch', () => {
86+
it('accepts semver release versions', () => {
87+
assert.equal(validateReleaseBranch('1.10'), 'release-1.10');
88+
assert.equal(validateReleaseBranch('1.10.2'), 'release-1.10.2');
89+
});
90+
91+
it('rejects unsafe branch suffixes', () => {
92+
assert.throws(() => validateReleaseBranch('release-1.10;rm -rf'), /invalid release version/);
93+
});
94+
});
95+
96+
describe('resolveCliPath', () => {
97+
it('requires absolute paths', () => {
98+
assert.throws(() => resolveCliPath('relative/path', 'test'), /path must be absolute/);
99+
});
100+
101+
it('resolves existing absolute paths', () => {
102+
const dir = mkdtempSync(join(tmpdir(), 'yarnlock-backport-test-'));
103+
try {
104+
const abs = realpathSync(dir);
105+
assert.equal(resolveCliPath(abs, 'test'), abs);
106+
} finally {
107+
rmSync(dir, { recursive: true, force: true });
108+
}
109+
});
110+
});
111+
112+
describe('assertPathWithin', () => {
113+
it('allows child paths within parent', () => {
114+
const parent = resolve('/tmp/repo');
115+
const child = join(parent, 'workspaces', 'orchestrator');
116+
assert.doesNotThrow(() => assertPathWithin(child, parent, 'test'));
117+
});
118+
119+
it('rejects paths that escape parent', () => {
120+
assert.throws(
121+
() => assertPathWithin(resolve('/tmp/other'), resolve('/tmp/repo'), 'test'),
122+
/path escapes allowed directory/,
123+
);
124+
});
125+
});
126+
127+
describe('validateGitRef', () => {
128+
it('accepts commit SHAs', () => {
129+
assert.equal(validateGitRef('eb6cce1234567890abcdef'), 'eb6cce1234567890abcdef');
130+
});
131+
132+
it('accepts tag names', () => {
133+
assert.equal(validateGitRef('v1.2.3'), 'v1.2.3');
134+
});
135+
136+
it('rejects path traversal', () => {
137+
assert.throws(() => validateGitRef('../../../etc/passwd'), /invalid git ref/);
138+
});
139+
});
140+
141+
describe('validateWorkspaceName', () => {
142+
it('accepts workspace directory names', () => {
143+
assert.equal(validateWorkspaceName('orchestrator'), 'orchestrator');
144+
});
145+
146+
it('rejects unsafe names', () => {
147+
assert.throws(() => validateWorkspaceName('../escape'), /invalid workspace name/);
148+
});
149+
});
150+
151+
describe('normalizeCveId', () => {
152+
it('accepts valid CVE ids', () => {
153+
assert.equal(normalizeCveId('cve-2026-1234'), 'CVE-2026-1234');
154+
});
155+
156+
it('rejects invalid CVE ids', () => {
157+
assert.throws(() => normalizeCveId('not-a-cve'), /not a CVE id/);
158+
});
159+
});
160+
161+
describe('parseCveCliToken', () => {
162+
it('parses CVE id without package override', () => {
163+
assert.deepEqual(parseCveCliToken('CVE-2026-1234'), ['CVE-2026-1234', []]);
164+
});
165+
166+
it('parses package override after slash', () => {
167+
assert.deepEqual(parseCveCliToken('CVE-2026-1234/axios'), ['CVE-2026-1234', ['axios']]);
168+
});
169+
});
170+
171+
describe('parseCveArg', () => {
172+
it('rejects duplicate CVE ids', () => {
173+
assert.throws(() => parseCveArg('CVE-2026-1234,CVE-2026-1234'), /duplicate CVE/);
174+
});
175+
});
176+
177+
describe('parseCveDetails', () => {
178+
it('returns empty details for rejected CVE records', () => {
179+
const result = parseCveDetails({ cveMetadata: { state: 'REJECTED' }, containers: { cna: {} } });
180+
assert.equal(result.name, '');
181+
assert.deepEqual(result.patch_versions, []);
182+
});
183+
184+
it('extracts lessThan fix version', () => {
185+
const result = parseCveDetails({
186+
cveMetadata: { state: 'PUBLISHED' },
187+
containers: {
188+
cna: {
189+
affected: [{ packageName: 'axios', versions: [{ status: 'affected', version: '0', lessThan: '1.18.1' }] }],
190+
},
191+
},
192+
});
193+
assert.equal(result.name, 'axios');
194+
assert.deepEqual(result.patch_versions, ['1.18.1']);
195+
});
196+
});
197+
198+
describe('maxVersion', () => {
199+
it('orders semver-like versions numerically', () => {
200+
const sorted = ['1.10.0', '1.9.0', '1.18.1'].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
201+
assert.deepEqual(sorted, ['1.9.0', '1.10.0', '1.18.1']);
202+
});
203+
});

0 commit comments

Comments
 (0)