@agile-team/wl-skills-gate 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,248 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync} from 'node:fs';
4
+ import {spawnSync} from 'node:child_process';
5
+ import {tmpdir} from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ // These are boundaries of the package-owned Jenkins adapter, not a claim that a
9
+ // text inspection validates Jenkins CPS or the site's credential permissions.
10
+ const native = () => readFileSync(new URL('../../templates/ci/jenkins-native.groovy', import.meta.url), 'utf8');
11
+
12
+ function endOfBlock(text, open) {
13
+ let depth = 1, quote = null;
14
+ for (let i = open + 1; i < text.length; i++) {
15
+ if (quote) {
16
+ if (text[i] === '\\') { i++; continue; }
17
+ if (text.startsWith(quote, i)) { i += quote.length - 1; quote = null; }
18
+ continue;
19
+ }
20
+ if (text.startsWith('//', i)) { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end; continue; }
21
+ if (text.startsWith('/*', i)) { const end = text.indexOf('*/', i + 2); assert.notEqual(end, -1, 'unterminated Groovy comment'); i = end + 1; continue; }
22
+ for (const delimiter of ["'''", '"""', "'", '"']) {
23
+ if (text.startsWith(delimiter, i)) { quote = delimiter; i += delimiter.length - 1; break; }
24
+ }
25
+ if (quote) continue;
26
+ if (text[i] === '{') depth++;
27
+ if (text[i] === '}' && --depth === 0) return i;
28
+ }
29
+ throw Error('unterminated Groovy block');
30
+ }
31
+
32
+ function opsMethod(source, name = 'wlOpsRun') {
33
+ const header = new RegExp('\\bdef\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{').exec(source);
34
+ assert(header, 'doctor must have the package-owned ' + name + ' adapter');
35
+ const open = header.index + header[0].lastIndexOf('{');
36
+ return source.slice(open + 1, endOfBlock(source, open));
37
+ }
38
+
39
+ function credentialBlocks(source) {
40
+ const scopes = [];
41
+ for (const match of source.matchAll(/\bwithCredentials\s*\(/g)) {
42
+ const open = source.indexOf('{', match.index);
43
+ assert(open >= 0, 'credential binding must have an explicit closure');
44
+ const end = endOfBlock(source, open);
45
+ scopes.push({declaration: source.slice(match.index, open), body: source.slice(open + 1, end)});
46
+ }
47
+ return scopes;
48
+ }
49
+
50
+ test('运维监督无需项目登记即可先盘点,检查与通知为三个独立动作', () => {
51
+ const helper = opsMethod(native());
52
+ const inventory = helper.indexOf('jenkins ops inventory');
53
+ const inspect = helper.indexOf('jenkins ops inspect');
54
+ const finish = helper.indexOf('jenkins ops finish');
55
+ assert(inventory >= 0 && inspect > inventory && finish > inspect, 'inventory → inspect → finish must be explicit');
56
+ assert.match(helper, /--cluster/);
57
+ assert.match(helper, /--target/);
58
+ assert.match(helper, /--inventory/);
59
+ assert.match(helper, /--results/);
60
+ assert.doesNotMatch(helper, /jenkins fleet (?:inventory|plan|apply)|jenkins (?:bind|plan)|review-publish|jenkins (?:admit|build)/,
61
+ 'ops supervision must not require the project registry or silently authorize a build/review');
62
+ });
63
+
64
+ test('源码检出和项目接入检查不持有Jenkins接口、飞书或签发凭据', () => {
65
+ const helper = opsMethod(native());
66
+ const scopes = credentialBlocks(helper);
67
+ assert(scopes.length >= 2, 'read credentials and notice credentials must be separately scoped');
68
+ for (const scope of scopes) {
69
+ assert.doesNotMatch(scope.body, /\bcheckout\s*\(|jenkins ops inspect/,
70
+ 'SCM checkout/inspection must happen outside API and bot credential closures');
71
+ assert.doesNotMatch(scope.declaration, /wl-jenkins-config-manager|wl-jenkins-build-dispatch|wl-review-signing-key|wl-quality-sso-client-secret/,
72
+ 'read-only supervision cannot obtain write, dispatch, signing or SSO credentials');
73
+ }
74
+ const reader = scopes.find(scope => scope.body.includes('jenkins ops inventory'));
75
+ const notifier = scopes.find(scope => scope.body.includes('jenkins ops finish'));
76
+ assert(reader, 'inventory must use a scoped Jenkins read credential');
77
+ assert(notifier, 'finish must use a scoped CI bot credential');
78
+ assert.match(reader.declaration, /wl-jenkins-ci-readonly/);
79
+ assert.doesNotMatch(reader.declaration, /feishu/);
80
+ assert.match(notifier.declaration, /wl-feishu-ci-webhook/);
81
+ assert.match(notifier.declaration, /wl-feishu-ci-signing-secret/);
82
+ assert.doesNotMatch(notifier.declaration, /wl-feishu-project|wl-jenkins-ci-readonly/);
83
+ });
84
+
85
+ test('项目检查失败仍进入汇总,运维报告归档且不启动旧部署任务', () => {
86
+ const helper = opsMethod(native());
87
+ assert.match(helper, /\btry\s*\{/);
88
+ assert.match(helper, /\bfinally\s*\{/,
89
+ 'inspection failures need a finally path for the aggregate report/notice');
90
+ assert.match(helper, /archiveArtifacts/);
91
+ assert.match(helper, /returnStatus\s*:\s*true/,
92
+ 'a missing project prerequisite must be recorded before ending the monitoring run');
93
+ assert.doesNotMatch(helper, /\bbuild\s*\(|\/buildWithParameters|\/build[?'"`\s]|createItem|config\.xml/,
94
+ 'supervision does not rewrite or execute discovered custom deployment jobs');
95
+ });
96
+
97
+ test('默认doctor执行监督,机器人测试仅走显式连通测试', () => {
98
+ const source = native();
99
+ assert.match(source, /cfg\.role\s*==\s*['"]node['"]/);
100
+ assert.match(source, /params\.TEST_NOTIFICATION/);
101
+ const call = source.lastIndexOf('wlOpsRun(cfg)');
102
+ assert(call > source.indexOf('def call('), 'node routing must invoke the ops adapter by default');
103
+ assert.match(source, /wlGateNotificationTest\(\)/);
104
+ });
105
+
106
+ test('承接仅启动持久claim给出的CHECK,启动后记录实际编号,未持有通知或签发凭据', () => {
107
+ const helper = opsMethod(native(), 'wlOpsContinue');
108
+ const claim = helper.indexOf('jenkins ops claim-check'), build = helper.indexOf('build('), ack = helper.indexOf('jenkins ops ack-check');
109
+ assert(claim >= 0 && build > claim && ack > build, 'durable claim → actual CHECK start → actual number acknowledgment');
110
+ assert.match(helper, /claim\.status\s*==\s*['"]claimed['"]/);
111
+ assert.match(helper, /build\(job:\s*claim\.job/);
112
+ assert.match(helper, /waitForStart:\s*true/);
113
+ assert.match(helper, /--build-number/);
114
+ assert.match(helper, /--dispatch-id/);
115
+ assert.match(helper, /started\.number/);
116
+ assert.match(helper, /name:\s*['"]WL_REVIEW_SHA['"],\s*value:\s*claim\.sha/);
117
+ assert.match(helper, /name:\s*['"]WL_GATE_DISPATCH_ID['"],\s*value:\s*claim\.dispatchId/);
118
+ for (const scope of credentialBlocks(helper)) {
119
+ assert.doesNotMatch(scope.declaration, /feishu|signing-key|build-dispatch|config-manager|sso-client-secret/);
120
+ assert.doesNotMatch(scope.body, /\bbuild\s*\(/, 'Jenkins interface credentials must end before the child CHECK starts');
121
+ }
122
+ assert.doesNotMatch(helper, /review-publish|jenkins (?:admit|build)|\/buildWithParameters/);
123
+ assert.match(helper, /archiveArtifacts/);
124
+ assert.match(opsMethod(native()), /wlOpsContinue\(cfg,\s*summary\)/);
125
+ });
126
+
127
+ test('人工终止不继续检出,通知凭据失败仍保留无发送汇总与归档', () => {
128
+ const helper = opsMethod(native());
129
+ assert.match(helper, /catch\s*\(org\.jenkinsci\.plugins\.workflow\.steps\.FlowInterruptedException interrupted\)\s*\{\s*throw interrupted\s*\}/);
130
+ assert.match(helper, /finally\s*\{\s*deleteDir\(\)\s*\}/);
131
+ const finishes = helper.split('\n').filter(command => /\bjenkins ops finish\b/.test(command));
132
+ assert(finishes.some(command => /--send\b/.test(command)), 'configured bot must send the summary');
133
+ assert(finishes.some(command => !/--send\b/.test(command)), 'missing credential path must still generate a non-sending summary');
134
+ assert.match(helper, /finally\s*\{\s*archiveArtifacts/);
135
+ });
136
+
137
+ test('原生Groovy执行监督与承接:凭据隔离、固定SHA、失败不漏档且继续其它项目', {skip: !process.env.WL_GROOVY_JAR}, t => {
138
+ const jar = process.env.WL_GROOVY_JAR;
139
+ assert(existsSync(jar), 'WL_GROOVY_JAR must point to an existing Groovy runtime');
140
+ const directory = mkdtempSync(path.join(tmpdir(), 'wl-ops-groovy-'));
141
+ t.after(() => rmSync(directory, {recursive: true, force: true}));
142
+ const harness = path.join(directory, 'ops.groovy');
143
+ writeFileSync(harness, String.raw`
144
+ def loader = new GroovyClassLoader()
145
+ def interruptedClass = loader.parseClass('package org.jenkinsci.plugins.workflow.steps; class FlowInterruptedException extends Exception { FlowInterruptedException(String message) { super(message) } }')
146
+ def parser = loader.parseClass('package groovy.json; class JsonSlurperClassic { static Map values=[:]; def parseText(String text) { assert values.containsKey(text):text; return values[text] } }')
147
+ def source = new File(args[0]).text
148
+ for (def mode in ['normal', 'no-bot', 'interrupted', 'claim-failure', 'claim-missing', 'claim-unknown', 'already-started', 'ack-failure']) {
149
+ def events=[], files=[:], env=[:], currentBuild=[:], working=[], credentials=0, builds=0, cleanups=0, startedKeys=[], acknowledgedKeys=[]
150
+ def multi=mode in ['claim-failure', 'claim-missing', 'claim-unknown', 'ack-failure']
151
+ def inventory=[runId:'fixture-run', jobsDiscovered:2, targets:[[key:'a'*24, checkoutAllowed:true, branch:'test', repository:'https://gitlab.example.invalid/group/produce.git', credentialId:'scm'],[key:'b'*24, checkoutAllowed:false, branch:null]]]
152
+ def summary=[runId:'fixture-run', globalGaps:(mode in ['no-bot','interrupted'] ? [[id:'pending']] : []), projects:[[key:'a'*24, status:'ready', checkJob:'produce/CHECK']]]
153
+ if(multi){inventory.targets[1]=[key:'b'*24,checkoutAllowed:true,branch:'test',repository:'https://gitlab.example.invalid/group/produce.git',credentialId:'scm'];summary.projects<<[key:'b'*24,status:'ready',checkJob:'produce/NEXT-CHECK']}
154
+ def claim=[status:'claimed', job:'produce/CHECK', sha:'c'*40, dispatchId:'ops-fixture-approved-check']
155
+ def nextClaim=[status:'claimed', job:'produce/NEXT-CHECK', sha:'d'*40, dispatchId:'ops-fixture-next-check']
156
+ parser.values=[inventory:inventory, summary:summary, claim:claim, nextClaim:nextClaim, unknown:[status:'unknown',reason:'fixture start unknown'], already:[status:'already-started',job:'produce/CHECK']]
157
+ Binding binding=new Binding([env:env, params:[:], currentBuild:currentBuild])
158
+ def shell=new GroovyShell(loader,binding)
159
+ binding.setVariable('pwd',{->'/workspace'})
160
+ binding.setVariable('dir',{location,body->working<<location;try{return body()}finally{working.remove(working.size()-1)}})
161
+ binding.setVariable('deleteDir',{->cleanups++;events<<'cleanup'})
162
+ binding.setVariable('writeFile',{options->files[options.file]=options.text})
163
+ binding.setVariable('readFile',{location->def key=location.toString();assert files.containsKey(key):key;return files[key]})
164
+ binding.setVariable('fileExists',{location->files.containsKey(location.toString())})
165
+ binding.setVariable('archiveArtifacts',{options->events<<'archive'})
166
+ binding.setVariable('stage',{name,body->events<<name;body()})
167
+ binding.setVariable('timeout',{options,body->body()})
168
+ binding.setVariable('echo',{message->events<<'echo'})
169
+ binding.setVariable('withEnv',{values,body->def old=new LinkedHashMap(env);values.each{value->def pair=value.split('=',2);env[pair[0]]=pair[1]};try{return body()}finally{env.clear();env.putAll(old)}})
170
+ ['usernamePassword','string','file'].each{name->binding.setVariable(name,{options->options})}
171
+ binding.setVariable('withCredentials',{values,body->
172
+ if(mode=='no-bot'&&values.any{it.credentialsId.startsWith('wl-feishu')})throw new RuntimeException('fixture missing bot credential')
173
+ def old=new LinkedHashMap(env);credentials++
174
+ values.each{value->if(value.variable)env[value.variable]='fixture';if(value.usernameVariable){env[value.usernameVariable]='fixture';env[value.passwordVariable]='fixture'}}
175
+ try{return body()}finally{credentials--;env.clear();env.putAll(old)}
176
+ })
177
+ binding.setVariable('checkout',{options->
178
+ assert credentials==0:'SCM checkout held API/bot credentials'
179
+ assert options.scm.branches[0].name=='origin/test'
180
+ assert options.scm.userRemoteConfigs[0].credentialsId=='scm'
181
+ events<<'checkout'
182
+ if(mode=='interrupted')throw interruptedClass.newInstance('fixture manual cancellation')
183
+ return [GIT_COMMIT:'c'*40]
184
+ })
185
+ binding.setVariable('build',{options->
186
+ assert credentials==0:'CHECK start held interface/bot credentials'
187
+ def expected=env.WL_OPS_KEY=='a'*24?claim:nextClaim
188
+ assert options.job==expected.job
189
+ assert options.waitForStart==true&&options.wait==false&&options.propagate==false
190
+ assert options.parameters.find{it.name=='WL_REVIEW_SHA'}.value==expected.sha
191
+ assert options.parameters.find{it.name=='WL_GATE_DISPATCH_ID'}.value==expected.dispatchId
192
+ builds++;startedKeys<<env.WL_OPS_KEY;events<<'CHECK-start';return [number:40+builds]
193
+ })
194
+ binding.setVariable('sh',{options->
195
+ String command=options instanceof Map?options.script:options
196
+ if(command.contains('jenkins ops inventory')){assert env.WL_JENKINS_READ_TOKEN;assert !env.WL_FEISHU_CI_SECRET;files['wl-ops-public/inventory.json']='inventory';events<<'inventory'}
197
+ else if(command.contains('jenkins ops inspect')){assert credentials==0;events<<'inspect'}
198
+ else if(command.contains('jenkins ops finish')){if(command.contains('--send'))assert env.WL_FEISHU_CI_SECRET;else assert credentials==0;files['wl-ops-public/summary.json']='summary';events<<'finish';return mode in ['no-bot','interrupted']?2:0}
199
+ else if(command.contains('jenkins ops claim-check')){
200
+ assert env.WL_JENKINS_READ_TOKEN;assert !env.WL_FEISHU_CI_SECRET;events<<'claim'
201
+ if(env.WL_OPS_KEY=='a'*24&&mode=='claim-failure')return 2
202
+ if(env.WL_OPS_KEY=='a'*24&&mode=='claim-missing')return 0
203
+ files[env.WL_OPS_CLAIM]=env.WL_OPS_KEY=='b'*24?'nextClaim':mode=='claim-unknown'?'unknown':mode=='already-started'?'already':'claim'
204
+ }
205
+ else if(command.contains('jenkins ops ack-check')){
206
+ def expected=env.WL_OPS_KEY=='a'*24?claim:nextClaim
207
+ assert env.WL_OPS_BUILD==(40+builds).toString()
208
+ assert env.WL_OPS_DISPATCH==expected.dispatchId
209
+ assert command.contains('--dispatch-id')
210
+ if(mode=='ack-failure'&&env.WL_OPS_KEY=='a'*24)throw new RuntimeException('fixture ACK write failed')
211
+ acknowledgedKeys<<env.WL_OPS_KEY;events<<'ack'
212
+ }
213
+ else assert false:command
214
+ return 0
215
+ })
216
+ def script=shell.parse(source,'native_ops.groovy');script.run()
217
+ def failure=null
218
+ try{script.wlOpsRun([gateRoot:'/fixed/gate',clusterFile:'/controlled/cluster.json'])}catch(Exception expected){failure=expected}
219
+ assert events.contains('inventory')&&events.contains('finish')&&events.contains('archive')
220
+ assert credentials==0
221
+ if(mode=='normal'){
222
+ assert failure==null:failure
223
+ assert builds==1&&events.indexOf('claim')<events.indexOf('CHECK-start')&&events.indexOf('CHECK-start')<events.indexOf('ack')
224
+ assert events.count('inspect')==2
225
+ }else if(mode=='no-bot'){
226
+ assert failure==null:failure
227
+ assert builds==0&&currentBuild.result=='UNSTABLE'
228
+ }else if(mode=='interrupted'){
229
+ assert interruptedClass.isInstance(failure)
230
+ assert builds==0&&events.count('checkout')==1&&events.count('inspect')==0
231
+ assert cleanups>=2
232
+ }else if(mode=='already-started'){
233
+ assert failure==null:failure
234
+ assert builds==0&&currentBuild.result==null
235
+ }else{
236
+ assert failure==null:failure
237
+ assert currentBuild.result=='UNSTABLE'
238
+ assert events.count('archive')>=2
239
+ if(mode=='ack-failure'){assert startedKeys==['a'*24,'b'*24];assert acknowledgedKeys==['b'*24]}
240
+ else{assert startedKeys==['b'*24];assert acknowledgedKeys==['b'*24]}
241
+ }
242
+ }
243
+ println 'Operations native Groovy mock passed: normal/no-bot/interrupted/claim-failure/claim-missing/claim-unknown/already-started/ack-failure.'
244
+ `);
245
+ const result = spawnSync(process.env.JAVA || 'java', ['-cp', jar, 'groovy.ui.GroovyMain', harness, new URL('../../templates/ci/jenkins-native.groovy', import.meta.url).pathname], {encoding: 'utf8', timeout: 30000});
246
+ assert.equal(result.status, 0, result.stderr + '\n' + result.stdout);
247
+ assert.match(result.stdout, /Operations native Groovy mock passed/);
248
+ });
@@ -0,0 +1,88 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createHmac } from 'node:crypto';
4
+ import { opsNotification, sendOpsNotification } from '../../lib/jenkins-ops-notification.js';
5
+
6
+ const report = () => ({ kind: 'jenkins-ops-report', checkedAt: '2026-10-22T01:02:03Z', jenkinsOrigin: 'https://jenkins.example.invalid/', runUrl: 'https://jenkins.example.invalid/job/doctor/7/', status: 'attention', globalGaps: [], projects: [{ key: 'p:test', project: 'wl-produce', branch: 'test', jobs: ['p-test'], status: 'attention', gaps: [{ id: 'gate-package-missing', message: '项目未安装Gate', action: '技术经理执行项目接入,再自动复检' }], technicalManager: 'manager', executor: 'author' }], remoteModified: false, qualityPassed: false, buildAuthorized: false });
7
+ const person = (name, id, verified = true) => ({ name, feishu: { idType: 'user_id', id, verified } });
8
+ const config = () => ({ people: { manager: person('经理', 'verified-manager'), author: person('执行人', 'verified-author'), observer: person('李月', 'verified-observer') }, owners: { 'wl-produce': { technicalManager: 'manager', managerSubmissionNotify: 'observer' } } });
9
+ const json = value => JSON.stringify(value);
10
+ const env = { WL_FEISHU_CI_WEBHOOK: 'https://open.feishu.cn/open-apis/bot/v2/hook/ci-fixture', WL_FEISHU_CI_SECRET: 'fixture-secret' };
11
+
12
+ test('运维缺项通知无需项目配置:时间、缺项、动作与真实@;不冒充质量通过', () => {
13
+ const out = opsNotification(report(), config()), content = json(out.payload);
14
+ assert.deepEqual(out.mentions.sort(), ['verified-author', 'verified-manager']);
15
+ assert.equal(out.mentionMappingComplete, true);
16
+ assert.match(content, /2026年10月22日 09:02:03/);
17
+ assert.match(content, /项目未安装Gate/); assert.match(content, /自动复检/); assert.match(content, /未接入不等于扫描通过/);
18
+ assert.match(content, /<at id=\\"verified-manager/); assert.match(content, /查看本次接入检查/);
19
+ });
20
+ test('姓名工号不推断飞书身份;无真实ID仍发送缺项卡', () => {
21
+ const c = config(); c.people.manager.feishu.verified = false; c.people.author = { name: '杨晨誉', employeeId: '409322' };
22
+ const out = opsNotification(report(), c);
23
+ assert.equal(out.mentionMappingComplete, false); assert.deepEqual(out.mentions, []);
24
+ assert.doesNotMatch(json(out.payload), /<at /); assert.match(json(out.payload), /真实 @ 尚未配置/);
25
+ });
26
+ test('同一人员去重,经理本人提交才同步指定观察人', () => {
27
+ const r = report(); r.projects[0].executor = 'manager';
28
+ const out = opsNotification(r, config()), content = json(out.payload);
29
+ assert.deepEqual(out.mentions.sort(), ['verified-manager', 'verified-observer']);
30
+ assert.equal((content.match(/<at id=\\"verified-manager/g) || []).length, 1);
31
+ assert.equal((content.match(/<at id=\\"verified-observer/g) || []).length, 1);
32
+ });
33
+ test('同一飞书ID的不同人员键去重;额外主责和公共兜底人都实际出现在卡片中', () => {
34
+ const r = report(), c = config(); c.people.author.feishu.id = c.people.manager.feishu.id;
35
+ c.people.ops = person('运维', 'verified-ops'); c.owners['wl-produce'].recipients = ['ops']; c.fallbackRecipients = ['observer'];
36
+ const out = opsNotification(r, c), content = json(out.payload);
37
+ assert.deepEqual(out.mentions.sort(), ['verified-manager', 'verified-observer', 'verified-ops']);
38
+ for (const id of out.mentions) assert.equal((content.match(new RegExp('<at id=\\\\"' + id, 'g')) || []).length, 1);
39
+ });
40
+ test('项目限前三摘要,但所有项目责任人仍提醒;不修改输入', () => {
41
+ const r = report(), c = config();
42
+ r.projects = Array.from({ length: 5 }, (_, i) => ({ ...r.projects[0], key: 'project-' + i, project: 'project-' + i, technicalManager: 'm' + i, executor: 'a' + i }));
43
+ for (let i = 0; i < 5; i++) { c.people['m' + i] = person('经理' + i, 'manager-' + i); c.people['a' + i] = person('执行人' + i, 'author-' + i); }
44
+ const before = json(r), out = opsNotification(r, c), content = json(out.payload);
45
+ assert.match(content, /3\/5/); assert.doesNotMatch(content, /project-3 ·/); assert.equal(out.mentions.length, 10); assert.equal(json(r), before);
46
+ });
47
+ test('仅真实同源HTTPS本次记录可点击,缺失地址不伪造详情页', () => {
48
+ for (const url of [undefined, 'http://localhost:8090/', 'https://attacker.invalid/', 'https://jenkins.example.invalid/?token=private']) {
49
+ const r = report(); r.runUrl = url; const out = opsNotification(r);
50
+ assert.doesNotMatch(json(out.payload), /open_url/); assert.doesNotMatch(json(out.payload), /private/);
51
+ }
52
+ });
53
+ test('全局缺项分类且限前两条;恢复卡绿色但仍要求九维和Review', () => {
54
+ const r = report(); r.globalGaps = Array.from({ length: 4 }, (_, i) => ({ id: 'credential-' + i, message: '公共凭据缺项' + i, action: '配置受控凭据' }));
55
+ assert.match(json(opsNotification(r).payload), /凭据/访问权限 4/);
56
+ r.globalGaps = []; r.projects[0].status = 'ready'; r.projects[0].gaps = []; r.status = 'ready'; r.recovered = true;
57
+ const out = opsNotification(r, config()); assert.equal(out.payload.card.header.template, 'green'); assert.match(json(out.payload), /转入现有九维检查与Review准入/);
58
+ r.globalGaps = [{ id: 'missing-config', message: '仍然缺配置' }]; assert.notEqual(opsNotification(r).payload.card.header.template, 'green');
59
+ });
60
+ test('只读取CI机器人、正确签名和飞书确认;密钥不出现在回执中', async () => {
61
+ let calls = 0;
62
+ const out = await sendOpsNotification(report(), config(), { env: { ...env, WL_FEISHU_PROJECT_WEBHOOK: 'https://wrong.invalid/' }, now: 123000, fetchImpl: async (url, options) => {
63
+ calls++; assert.equal(url, env.WL_FEISHU_CI_WEBHOOK); assert.equal(options.redirect, 'error');
64
+ const body = JSON.parse(options.body); assert.equal(body.timestamp, '123');
65
+ assert.equal(body.sign, createHmac('sha256', '123\nfixture-secret').update('').digest('base64')); assert.doesNotMatch(options.body, /fixture-secret/);
66
+ return { status: 200, text: async () => '{"code":0}' };
67
+ } });
68
+ assert.equal(calls, 1); assert.equal(out.status, 'sent'); assert.equal(out.qualityPassed, false); assert.doesNotMatch(json(out), /fixture-secret|ci-fixture/);
69
+ });
70
+ test('超时未知不重发;HTTP和provider错误不可记sent;异常文本去敏', async () => {
71
+ let calls = 0;
72
+ const unknown = await sendOpsNotification(report(), config(), { env, fetchImpl: async () => { calls++; throw new Error('private-secret-token'); } });
73
+ assert.equal(calls, 1); assert.equal(unknown.status, 'unknown'); assert.doesNotMatch(json(unknown), /private-secret-token/);
74
+ for (const response of [{ status: 403 }, { status: 200, text: async () => '{"code":19021}' }, { status: 200, text: async () => '{"code":19021,"StatusCode":0}' }, { status: 200, text: async () => '{}' }]) assert.equal((await sendOpsNotification(report(), {}, { env, fetchImpl: async () => response })).status, 'rejected');
75
+ assert.equal((await sendOpsNotification(report(), {}, { env, fetchImpl: async () => ({ status: 200, text: async () => 'bad-json' }) })).status, 'unknown');
76
+ });
77
+ test('不降级项目机器人;带凭据地址拒绝;无@不会阻止有效CI消息送达', async () => {
78
+ let calls = 0; const fetchImpl = async () => { calls++; return { status: 200, text: async () => '{"StatusCode":0}' }; };
79
+ assert.equal((await sendOpsNotification(report(), {}, { env: { WL_FEISHU_PROJECT_WEBHOOK: env.WL_FEISHU_CI_WEBHOOK, WL_FEISHU_PROJECT_SECRET: env.WL_FEISHU_CI_SECRET }, fetchImpl })).status, 'not-sent');
80
+ assert.equal((await sendOpsNotification(report(), {}, { env: { ...env, WL_FEISHU_CI_WEBHOOK: 'https://name:password@open.feishu.cn/open-apis/bot/v2/hook/ci' }, fetchImpl })).status, 'not-sent');
81
+ assert.equal(calls, 0);
82
+ const out = await sendOpsNotification(report(), {}, { env, fetchImpl }); assert.equal(out.status, 'sent'); assert.equal(out.mentionMappingComplete, false); assert.equal(calls, 1);
83
+ });
84
+ test('缺项信息中的token或地址凭据去敏;报告类型无效拒绝', () => {
85
+ const r = report(); r.projects[0].gaps[0].message = 'secret=really-private https://name:pass@internal.invalid/path?token=private';
86
+ const content = json(opsNotification(r).payload); assert.doesNotMatch(content, /really-private|name:pass|token=private/);
87
+ assert.throws(() => opsNotification({}), /真实运维接入检查报告/);
88
+ });
@@ -0,0 +1,238 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import {existsSync, readFileSync, rmSync} from 'node:fs';
5
+ import {randomUUID, createHmac} from 'node:crypto';
6
+ import {fixture, git, put, commit, bind} from '../test-support/jenkins-fixture.mjs';
7
+ import {planJenkinsInstallation, applyJenkinsInstallation} from '../../lib/jenkins-installation.js';
8
+ import {inventoryOps, inspectOpsProject, finishOps, opsReportHtml} from '../../lib/jenkins-ops.js';
9
+ import {nativePipelineEntry} from '../../lib/jenkins-entry-contract.js';
10
+ import {renderFleetJob} from '../../lib/jenkins-job-config.js';
11
+
12
+ const repository = 'https://gitlab.example.invalid/produce/wl-produce.git';
13
+ const readEnv = {WL_JENKINS_READ_USER: 'readonly', WL_JENKINS_READ_TOKEN: 'fixture-read-secret'};
14
+ const noticeEnv = {
15
+ WL_FEISHU_CI_WEBHOOK: 'https://open.feishu.cn/open-apis/bot/v2/hook/test-fixture-ci',
16
+ WL_FEISHU_CI_SECRET: 'fixture-ci-signing-secret',
17
+ JOB_NAME: 'wl-quality-doctor', BUILD_NUMBER: '21',
18
+ BUILD_URL: 'https://jenkins.example.invalid/job/wl-quality-doctor/21/',
19
+ };
20
+ const escapeXml = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
21
+ const scmXml = (branch = 'test') => `<flow-definition><properties/><definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition"><scm class="hudson.plugins.git.GitSCM"><userRemoteConfigs><hudson.plugins.git.UserRemoteConfig><url>${repository}</url><credentialsId>scm-readonly</credentialsId></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs><branches><hudson.plugins.git.BranchSpec><name>${branch}</name></hudson.plugins.git.BranchSpec></branches></scm><scriptPath>Jenkinsfile</scriptPath></definition><disabled>false</disabled></flow-definition>`;
22
+ const inlineXml = script => `<flow-definition><properties/><definition class="org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition"><script>${escapeXml(script)}</script><sandbox>true</sandbox></definition><disabled>false</disabled></flow-definition>`;
23
+
24
+ function install(t) {
25
+ const f = fixture(t);
26
+ git(f.root, 'remote', 'set-url', 'origin', repository);
27
+ const options = {prefix: path.join(f.base, 'install'), configDir: path.join(f.base, 'config'), registryFiles: [f.registryFile], jenkinsOrigin: 'https://jenkins.example.invalid/', centralAgentLabel: 'central', reviewSignerAgentLabel: 'signer'};
28
+ const plan = planJenkinsInstallation(options);
29
+ const installed = applyJenkinsInstallation({...options, confirm: true, planHash: plan.planHash});
30
+ const configFile = path.join(options.configDir, 'jenkins-ops.json');
31
+ const config = JSON.parse(readFileSync(configFile));
32
+ config.people = {manager: {name: '技术经理', feishu: {idType: 'open_id', id: 'ou_fixture_manager', verified: true}}};
33
+ config.owners = {'wl-produce': {technicalManager: 'manager'}};
34
+ put(options.configDir, 'jenkins-ops.json', config);
35
+ return {...f, installed, clusterFile: installed.clusterFile, configDir: options.configDir};
36
+ }
37
+
38
+ function jenkinsProvider(jobs) {
39
+ const calls = [];
40
+ const fetchImpl = async (url, options = {}) => {
41
+ const endpoint = new URL(url);
42
+ calls.push({url: endpoint.href, method: options.method || 'GET'});
43
+ assert.equal(endpoint.origin, 'https://jenkins.example.invalid');
44
+ assert.equal(options.method, 'GET', 'ops inventory must never write Jenkins');
45
+ assert.equal(options.headers.Authorization, 'Basic ' + Buffer.from('readonly:fixture-read-secret').toString('base64'));
46
+ const segments = endpoint.pathname.split('/').filter(Boolean), job = [];
47
+ let index = 0;
48
+ while (segments[index] === 'job') { job.push(decodeURIComponent(segments[index + 1])); index += 2; }
49
+ const suffix = segments.slice(index).join('/'), full = job.join('/');
50
+ if (suffix === 'api/json') {
51
+ const children = new Map();
52
+ for (const name of jobs.keys()) {
53
+ if (full && !name.startsWith(full + '/')) continue;
54
+ const parts = (full ? name.slice(full.length + 1) : name).split('/');
55
+ children.set(parts[0], {name: parts[0], _class: parts.length > 1 ? 'com.cloudbees.hudson.plugins.folder.Folder' : 'org.jenkinsci.plugins.workflow.job.WorkflowJob'});
56
+ }
57
+ return new Response(JSON.stringify({jobs: [...children.values()]}));
58
+ }
59
+ if (suffix === 'config.xml') return jobs.has(full) ? new Response(jobs.get(full)) : new Response('', {status: 404});
60
+ throw Error('unexpected read route: ' + endpoint.pathname);
61
+ };
62
+ return {calls, fetchImpl};
63
+ }
64
+
65
+ const freshInventory = inventory => ({...inventory, runId: randomUUID()});
66
+ const failureResult = (f, inventory) => inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: inventory.targets[0].key, failure: 'checkout-not-completed'});
67
+ const inspection = (f, inventory) => inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: inventory.targets[0].key, root: f.root});
68
+
69
+ async function controlledProject(t) {
70
+ const f = install(t);
71
+ put(f.root, 'pom.xml', '<project><modelVersion>4.0.0</modelVersion><artifactId>fixture</artifactId><profiles><profile><id>wl-quality-audit</id></profile><profile><id>wl-quality-unit</id></profile></profiles></project>');
72
+ put(f.root, '.wl-skills-bd/capabilities.json', {schemaVersion: 1, fixture: true});
73
+ const policy = JSON.parse(readFileSync(path.join(f.root, 'quality/policy.yml')));
74
+ policy.checks[0].profiles.push('release'); policy.releaseRequires = ['FIXTURE'];
75
+ put(f.root, 'quality/policy.yml', policy);
76
+ commit(f.root);
77
+ const seed = JSON.parse(readFileSync(f.seedFile)); seed.binding.repository = repository;
78
+ put(f.base, 'seed.json', seed);
79
+ await bind(f);
80
+ const registry = JSON.parse(readFileSync(f.registryFile)); registry.nativeEntry = f.installed.nativeEntry;
81
+ put(f.base, 'bindings.json', registry);
82
+ // Readiness performs file-level handover checks. These controlled fixture
83
+ // documents are not used to pretend a real SSO, Review signer or build ran.
84
+ for (const name of ['automation-config', 'governance-config', 'control-config', 'review-publish-config']) {
85
+ put(f.configDir, name + '.json', {schemaVersion: 1, projects: {'wl-produce': {}}, sources: [{id: 'fixture'}], dataDir: f.base, publicKeyFile: 'approved-fixture.pem', identity: {provider: 'fixture'}, authorizationIssuer: 'fixture', reviewPrivateKeyEnv: 'FIXTURE_PRIVATE_FILE'});
86
+ }
87
+ put(f.configDir, 'jenkins-fleet.json', {schemaVersion: 1, kind: 'wl-gate-jenkins-fleet', jenkinsOrigin: 'https://jenkins.example.invalid/', dataDir: path.join(f.base, 'fleet'), registryFiles: [f.registryFile], automationConfigFile: path.join(f.configDir, 'automation-config.json'), selection: {projects: ['wl-produce'], branches: ['test']}, approvers: ['fixture']});
88
+ const binding = registry.bindings[0];
89
+ const xml = renderFleetJob({script: nativePipelineEntry(f.installed.nativeEntry), binding, mode: 'check', secret: 'fixture-gitlab-hook-secret'});
90
+ const jobs = new Map([[binding.jobs.check, xml]]), provider = jenkinsProvider(jobs);
91
+ const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
92
+ return {...f, registry, binding, jobs, provider, inventory};
93
+ }
94
+
95
+ async function readyReport(f, inventory = f.inventory) {
96
+ const result = inspection(f, inventory);
97
+ assert.equal(result.status, 'ready', JSON.stringify(result.gaps));
98
+ assert.equal(result.pinsVerified, true);
99
+ const report = await finishOps({clusterFile: f.clusterFile, inventory, results: [result], send: false});
100
+ assert.equal(report.status, 'ready', JSON.stringify(report.globalGaps));
101
+ return report;
102
+ }
103
+
104
+ test('无登记/中央配置也能真实盘点、发现项目缺Gate并通过CI机器人持久提醒;不变不刷屏', async t => {
105
+ const f = install(t);
106
+ rmSync(path.join(f.root, 'quality'), {recursive: true});
107
+ commit(f.root);
108
+ const provider = jenkinsProvider(new Map([['生产/后端-test', scmXml()]]));
109
+ const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
110
+ assert(!existsSync(f.registryFile));
111
+ assert(!existsSync(path.join(f.configDir, 'automation-config.json')));
112
+ assert.equal(inventory.targets.length, 1);
113
+ assert.equal(inventory.targets[0].checkoutAllowed, true);
114
+ assert.equal(inventory.remoteModified, false);
115
+ assert.equal(provider.calls.every(call => call.method === 'GET'), true);
116
+ const result = inspection(f, inventory);
117
+ assert.equal(result.status, 'attention');
118
+ assert(result.gaps.some(g => g.id === 'PROJECT-GATE'));
119
+ assert(result.gaps.some(g => g.id === 'UNCONTROLLED-JOB'));
120
+ const sent = [];
121
+ const send = async (url, options) => {
122
+ assert.equal(url, noticeEnv.WL_FEISHU_CI_WEBHOOK);
123
+ const card = JSON.parse(options.body);
124
+ assert.equal(card.sign, createHmac('sha256', `${card.timestamp}\n${noticeEnv.WL_FEISHU_CI_SECRET}`).update('').digest('base64'));
125
+ sent.push(card);
126
+ return new Response(JSON.stringify({code: 0}));
127
+ };
128
+ const report = await finishOps({clusterFile: f.clusterFile, inventory, results: [result], env: noticeEnv, send: true, fetchImpl: send});
129
+ assert.equal(report.notification.status, 'sent');
130
+ assert.equal(report.notification.mentionMappingComplete, true);
131
+ assert.deepEqual(report.notification.mentions, ['ou_fixture_manager']);
132
+ assert.equal(report.qualityPassed, false);
133
+ assert.equal(report.buildAuthorized, false);
134
+ assert.match(JSON.stringify(sent[0]), /PROJECT-GATE|quality\/workspace|尚未完整接入Gate/);
135
+ const state = JSON.parse(readFileSync(path.join(f.configDir, 'ops-state/state.json')));
136
+ assert(Object.values(state.notices).some(item => item.status === 'sent' && item.runId === inventory.runId));
137
+ assert(existsSync(path.join(f.configDir, 'ops-state/runs', inventory.runId, 'report.json')));
138
+ const next = freshInventory(inventory);
139
+ const again = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [inspection(f, next)], env: noticeEnv, send: true, fetchImpl: send});
140
+ assert.equal(again.notification.status, 'unchanged');
141
+ assert.equal(sent.length, 1);
142
+ });
143
+
144
+ test('飞书送达未知保留待核对;下一轮相同缺项不会自动补发', async t => {
145
+ const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
146
+ const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
147
+ let sends = 0;
148
+ const unknown = async () => { sends++; throw Error('fixture timeout after server may have accepted'); };
149
+ const first = await finishOps({clusterFile: f.clusterFile, inventory, results: [failureResult(f, inventory)], env: noticeEnv, send: true, fetchImpl: unknown});
150
+ assert.equal(first.notification.status, 'unknown');
151
+ const next = freshInventory(inventory);
152
+ const second = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [failureResult(f, next)], env: noticeEnv, send: true, fetchImpl: unknown});
153
+ assert.equal(second.notification.status, 'unknown');
154
+ assert.equal(sends, 1);
155
+ const state = JSON.parse(readFileSync(path.join(f.configDir, 'ops-state/state.json')));
156
+ assert(Object.values(state.notices).some(item => item.status === 'unknown'));
157
+ });
158
+
159
+ test('动态参数不猜固定分支,多仓库不擅选;feature分支不进入目标检查', async t => {
160
+ const f = install(t);
161
+ const dynamic = `pipeline { agent any\nenvironment {\nGIT_URL = '${repository}'\nGIT_CREDENTIAL_ID = 'scm-readonly'\n}\nstages { stage('checkout') { steps { git branch: env.TAG, credentialsId: env.GIT_CREDENTIAL_ID, url: env.GIT_URL } } } }`;
162
+ const multi = scmXml().replace('</userRemoteConfigs>', '<hudson.plugins.git.UserRemoteConfig><url>https://gitlab.example.invalid/common/shared.git</url><credentialsId>scm-readonly</credentialsId></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs>');
163
+ const provider = jenkinsProvider(new Map([['参数分支', inlineXml(dynamic)], ['多仓库', multi], ['自己的分支', scmXml('feature/order-fix')]]));
164
+ const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
165
+ assert.equal(inventory.jobsDiscovered, 3);
166
+ assert.equal(inventory.targets.length, 2);
167
+ assert(!inventory.targets.some(target => target.job === '自己的分支'));
168
+ for (const target of inventory.targets) {
169
+ assert.equal(target.checkoutAllowed, false);
170
+ assert.equal(target.branch, null);
171
+ assert(target.gaps.some(g => g.id === 'SCM-LOCATION'));
172
+ const result = inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: target.key, failure: 'checkout-not-completed'});
173
+ assert.equal(result.status, 'attention');
174
+ assert.equal(result.sha, undefined);
175
+ }
176
+ });
177
+
178
+ test('检出失败、缺失结果、重复结果和旧runId均保留待处理;不复用历史绿灯', async t => {
179
+ const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
180
+ const first = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
181
+ const failed = failureResult(f, first);
182
+ assert(failed.gaps.some(g => g.id === 'CHECKOUT'));
183
+ assert.equal(failed.sha, undefined);
184
+ assert.equal(failed.qualityPassed, false);
185
+ for (const mode of ['failed', 'missing', 'old', 'duplicate']) {
186
+ const inventory = freshInventory(first), current = {...failed, runId: inventory.runId};
187
+ const results = mode === 'missing' ? [] : mode === 'old' ? [failed] : mode === 'duplicate' ? [current, current] : [current];
188
+ const report = await finishOps({clusterFile: f.clusterFile, inventory, results, send: false});
189
+ assert.equal(report.projects[0].status, 'attention', mode);
190
+ assert(report.projects[0].gaps.some(g => g.id === (mode === 'failed' ? 'CHECKOUT' : 'INSPECTION-MISSING')), mode);
191
+ assert.equal(report.buildAuthorized, false, mode);
192
+ }
193
+ });
194
+
195
+ test('干净检出没有node_modules是待固定依赖准备,不误报未声明Gate', async t => {
196
+ const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
197
+ assert(!existsSync(path.join(f.root, 'quality-tools/node_modules')));
198
+ const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
199
+ const report = inspection(f, inventory);
200
+ assert.equal(report.projectKind, 'backend');
201
+ assert(!report.gaps.some(g => g.id === 'PROJECT-GATE' || /^(?:INSTALLED-|ENGINES-)/.test(g.id)));
202
+ assert(report.dependenciesPreparedByCheck.includes('INSTALLED-GATE'));
203
+ assert(report.dependenciesPreparedByCheck.includes('INSTALLED-BD'));
204
+ assert(!report.gaps.some(g => g.id === 'PIN-GATE'));
205
+ assert.equal(report.qualityPassed, false);
206
+ assert.equal(report.buildAuthorized, false);
207
+ });
208
+
209
+ test('真实接入前置恢复后发送恢复提醒;同一缺项复发不能被历史sent吞掉', async t => {
210
+ const f = await controlledProject(t), first = f.inventory;
211
+ assert.equal(first.errors.length, 0);
212
+ let calls = 0;
213
+ const send = async () => { calls++; return new Response(JSON.stringify({code: 0})); };
214
+ const before = await finishOps({clusterFile: f.clusterFile, inventory: first, results: [failureResult(f, first)], env: noticeEnv, send: true, fetchImpl: send});
215
+ assert.equal(before.status, 'attention');
216
+ assert.equal(before.notification.status, 'sent');
217
+ const next = freshInventory(first), result = inspection(f, next);
218
+ assert.equal(result.status, 'ready', JSON.stringify(result.gaps));
219
+ assert.equal(result.pinsVerified, true);
220
+ const after = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [result], env: noticeEnv, send: true, fetchImpl: send});
221
+ assert.equal(after.status, 'ready');
222
+ assert.equal(after.recovered, true);
223
+ assert.equal(after.notification.status, 'sent');
224
+ assert.equal(after.qualityPassed, false);
225
+ assert.equal(after.buildAuthorized, false);
226
+ const recurring = freshInventory(first);
227
+ const recurrence = await finishOps({clusterFile: f.clusterFile, inventory: recurring, results: [failureResult(f, recurring)], env: noticeEnv, send: true, fetchImpl: send});
228
+ assert.equal(recurrence.notification.status, 'sent', 'a new recurrence after recovery needs a fresh reminder');
229
+ assert.equal(calls, 3);
230
+ });
231
+
232
+ test('HTML把项目、Job和缺项当不可信文本转义,不形成脚本与属性注入', () => {
233
+ const html = opsReportHtml({checkedAt: '<script>alert(1)</script>', status: 'attention', globalGaps: [{message: '<img src=x onerror=alert(1)>', action: '& " < >'}], projects: [{key: 'x" onclick="alert(1)', project: '<script>project()</script>', branch: '<b>test</b>', jobs: ['<img src=x>'], gaps: [{id: '<svg>', message: '<iframe>', action: '<script>fix()</script>'}]}]});
234
+ assert.doesNotMatch(html, /<script>|<img\b|<iframe>|<svg>|<b>test/);
235
+ assert.match(html, /&lt;script&gt;project\(\)&lt;\/script&gt;/);
236
+ assert.match(html, /id="x&quot; onclick=&quot;alert\(1\)"/);
237
+ assert.match(html, /&amp; &quot; &lt; &gt;/);
238
+ });
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "kind": "wl-quality-ci-check",
4
4
  "project": "REPLACE_WITH_ACTUAL_PROJECT",
5
- "gateVersion": "0.13.1",
5
+ "gateVersion": "0.13.2",
6
6
  "workspaceHash": "REPLACE_WITH_APPROVED_WORKSPACE_SHA256",
7
7
  "lockHash": "REPLACE_WITH_APPROVED_LOCK_SHA256",
8
8
  "policyHash": "REPLACE_WITH_APPROVED_POLICY_SHA256",
@@ -55,7 +55,7 @@ def preflight(env):
55
55
  valid_root = root.is_absolute() and root.is_dir()
56
56
  check("gate-root", valid_root, "固定 Gate 目录可读取" if valid_root else "固定 Gate 目录不存在或不可读取",
57
57
  "在该Jenkins节点运行本包的接入Jenkins脚本;使用安装回执中的 gateHome。包内入口为 bin/wl-skills-gate.js。")
58
- expected = env.get("WL_BOOTSTRAP_GATE_VERSION", "0.13.1")
58
+ expected = env.get("WL_BOOTSTRAP_GATE_VERSION", "0.13.2")
59
59
  try:
60
60
  package = json.loads((root / "package.json").read_text())
61
61
  correct = package.get("name") == "@agile-team/wl-skills-gate" and package.get("version") == expected