start-command 0.39.1 → 0.40.0
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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -1
- package/eslint.config.mjs +1 -0
- package/package.json +9 -9
- package/src/bin/cli.js +7 -5
- package/src/lib/command-stream.js +19 -5
- package/src/lib/execution-chain.js +2 -2
- package/src/lib/execution-store.js +62 -44
- package/src/lib/failure-handler.js +12 -6
- package/src/lib/log-sanitizer.js +191 -229
- package/src/lib/log-uploader.js +6 -3
- package/test/ci-workflow-invariants.js +18 -0
- package/test/command-stream-log.js +85 -0
- package/test/dependency-freshness.mjs +552 -0
- package/test/execution-store-clink.js +152 -0
- package/test/execution-store.js +49 -5
- package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
- package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
- package/test/javascript-first-ci.js +29 -0
- package/test/log-sanitizer.js +387 -151
- package/test/regression-187.js +4 -0
- package/test/regression-224.js +4 -2
- package/test/regression-231.js +20 -0
- package/test/release-git.mjs +2 -1
package/src/lib/log-sanitizer.js
CHANGED
|
@@ -1,267 +1,229 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Prepares logs and issue text for publication with
|
|
3
|
+
* @link-foundation/sensitive-data-sanitizer, the sanitizer shared by
|
|
4
|
+
* start-command, gh-upload-log and hive-mind (issue #250).
|
|
5
|
+
*
|
|
6
|
+
* The library masks credentials only (--credentials-only), plus the values of
|
|
7
|
+
* credential-named environment variables (--env-secrets) and the tokens in the
|
|
8
|
+
* local gh hosts.yml (--gh-config). It streams the log from stdin, keeps
|
|
9
|
+
* invalid UTF-8 bytes and writes a new private file atomically (--output).
|
|
10
|
+
*
|
|
11
|
+
* Since 0.12.1 the library masks only the scheme and token of an
|
|
12
|
+
* `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
|
|
13
|
+
* keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
|
|
14
|
+
* pre-pass that did this here is gone (issue #253).
|
|
15
|
+
*/
|
|
16
|
+
|
|
2
17
|
const fs = require('fs');
|
|
3
18
|
const os = require('os');
|
|
4
19
|
const path = require('path');
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
5
21
|
|
|
6
22
|
const BLOCK_SIZE = 64 * 1024;
|
|
7
23
|
const MARKER = '[REDACTED]';
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// continuation state, so a token or header larger than a block cannot leak its
|
|
13
|
-
// suffix. Latin-1 provides a reversible byte mapping, including invalid UTF-8.
|
|
14
|
-
const RULES = [
|
|
15
|
-
{
|
|
16
|
-
pattern:
|
|
17
|
-
/\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
|
|
18
|
-
validate: (value) => {
|
|
19
|
-
try {
|
|
20
|
-
return (
|
|
21
|
-
typeof JSON.parse(
|
|
22
|
-
Buffer.from(value.split('.')[0], 'base64url').toString()
|
|
23
|
-
).alg === 'string'
|
|
24
|
-
);
|
|
25
|
-
} catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
continuation: /[A-Za-z0-9_.-]/,
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
|
|
33
|
-
continuation: /[A-Za-z0-9_.-]/,
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
|
|
37
|
-
continuation: /[A-Za-z0-9_-]/,
|
|
38
|
-
},
|
|
39
|
-
{ pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
|
|
40
|
-
{ pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
|
|
41
|
-
{
|
|
42
|
-
pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
|
|
43
|
-
continuation: /[^\r\n]/,
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
pattern:
|
|
47
|
-
/(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
|
|
48
|
-
continuation: /[^\s"';,]/,
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
|
|
52
|
-
continuation: /[^\s@]/,
|
|
53
|
-
},
|
|
54
|
-
];
|
|
24
|
+
const PACKAGE = '@link-foundation/sensitive-data-sanitizer';
|
|
25
|
+
const COMMAND = 'sensitive-data-sanitizer';
|
|
26
|
+
const OVERRIDE = 'START_SENSITIVE_DATA_SANITIZER';
|
|
27
|
+
const OPTIONS = ['--credentials-only', '--env-secrets', '--gh-config'];
|
|
55
28
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.filter(
|
|
62
|
-
([name, value]) =>
|
|
63
|
-
SECRET_ENV_NAME.test(name) &&
|
|
64
|
-
typeof value === 'string' &&
|
|
65
|
-
value.length
|
|
66
|
-
)
|
|
67
|
-
.map(([, value]) => Buffer.from(value).toString('latin1'))
|
|
68
|
-
),
|
|
69
|
-
].sort((a, b) => b.length - a.length);
|
|
70
|
-
if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
|
|
71
|
-
throw new Error(
|
|
72
|
-
'Known environment secret is too large for bounded sanitization.'
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
this.holdback = this.secrets.reduce(
|
|
76
|
-
(size, value) => Math.max(size, value.length),
|
|
77
|
-
2048
|
|
29
|
+
/** Value-free failure: `code` is a fixed ERR_* name, never log content. */
|
|
30
|
+
class SanitizationError extends Error {
|
|
31
|
+
constructor(code, subject = 'Log') {
|
|
32
|
+
super(
|
|
33
|
+
`${subject} sanitization failed (${code}); ${subject === 'Log' ? 'upload' : 'publication'} blocked.`
|
|
78
34
|
);
|
|
79
|
-
this.
|
|
80
|
-
this.continuation = null;
|
|
81
|
-
this.redactions = 0;
|
|
35
|
+
this.code = code;
|
|
82
36
|
}
|
|
37
|
+
}
|
|
83
38
|
|
|
84
|
-
|
|
85
|
-
|
|
39
|
+
function isFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(filePath).isFile();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
86
44
|
}
|
|
45
|
+
}
|
|
87
46
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!rule.validate || rule.validate(match[0])) {
|
|
98
|
-
matches.push({
|
|
99
|
-
index: match.index,
|
|
100
|
-
end: match.index + match[0].length,
|
|
101
|
-
continuation: rule.continuation,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
for (const secret of this.secrets) {
|
|
107
|
-
for (
|
|
108
|
-
let index = input.indexOf(secret);
|
|
109
|
-
index >= 0;
|
|
110
|
-
index = input.indexOf(secret, index + 1)
|
|
111
|
-
) {
|
|
112
|
-
matches.push({ index, end: index + secret.length });
|
|
113
|
-
}
|
|
47
|
+
function findBundledCli() {
|
|
48
|
+
// Walk up node_modules directories the way require() does; the package
|
|
49
|
+
// exports only ESM, so require.resolve() cannot locate its files.
|
|
50
|
+
const relative = path.join(PACKAGE, 'bin', `${COMMAND}.js`);
|
|
51
|
+
let directory = __dirname;
|
|
52
|
+
for (;;) {
|
|
53
|
+
const candidate = path.join(directory, 'node_modules', relative);
|
|
54
|
+
if (isFile(candidate)) {
|
|
55
|
+
return candidate;
|
|
114
56
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const previous = merged[merged.length - 1];
|
|
119
|
-
if (previous && match.index <= previous.end) {
|
|
120
|
-
if (match.end > previous.end) {
|
|
121
|
-
previous.end = match.end;
|
|
122
|
-
previous.continuation = match.continuation;
|
|
123
|
-
} else if (match.end === previous.end && match.continuation) {
|
|
124
|
-
// Nested tokens must not narrow a containing header/URL's suffix.
|
|
125
|
-
const retained = previous.continuation;
|
|
126
|
-
const additional = match.continuation;
|
|
127
|
-
previous.continuation = retained
|
|
128
|
-
? { test: (byte) => retained.test(byte) || additional.test(byte) }
|
|
129
|
-
: additional;
|
|
130
|
-
}
|
|
131
|
-
} else {
|
|
132
|
-
merged.push({ ...match });
|
|
133
|
-
}
|
|
57
|
+
const parent = path.dirname(directory);
|
|
58
|
+
if (parent === directory) {
|
|
59
|
+
return null;
|
|
134
60
|
}
|
|
135
|
-
|
|
61
|
+
directory = parent;
|
|
136
62
|
}
|
|
63
|
+
}
|
|
137
64
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the library CLI: START_SENSITIVE_DATA_SANITIZER, then the bundled
|
|
67
|
+
* dependency run by the current runtime, then sensitive-data-sanitizer on PATH.
|
|
68
|
+
* @returns {{command: string, args: string[]}|null}
|
|
69
|
+
*/
|
|
70
|
+
function resolveSanitizerCommand(env = process.env) {
|
|
71
|
+
if (env[OVERRIDE]) {
|
|
72
|
+
return { command: env[OVERRIDE], args: [] };
|
|
73
|
+
}
|
|
74
|
+
const bundled = findBundledCli();
|
|
75
|
+
if (bundled) {
|
|
76
|
+
return { command: process.execPath, args: [bundled] };
|
|
77
|
+
}
|
|
78
|
+
const { resolveCommand } = require('./log-uploader');
|
|
79
|
+
const command = resolveCommand(COMMAND);
|
|
80
|
+
return command ? { command, args: [] } : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function runSanitizer(args, options, subject) {
|
|
84
|
+
const env = options.env || process.env;
|
|
85
|
+
const resolved = resolveSanitizerCommand(env);
|
|
86
|
+
if (!resolved) {
|
|
87
|
+
throw new SanitizationError('ERR_UNAVAILABLE', subject);
|
|
88
|
+
}
|
|
89
|
+
const result = spawnSync(resolved.command, [...resolved.args, ...args], {
|
|
90
|
+
env,
|
|
91
|
+
input: options.input,
|
|
92
|
+
stdio: options.stdio || ['pipe', 'pipe', 'pipe'],
|
|
93
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
94
|
+
shell:
|
|
95
|
+
process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolved.command),
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
});
|
|
98
|
+
if (result.error) {
|
|
99
|
+
throw new SanitizationError('ERR_UNAVAILABLE', subject);
|
|
100
|
+
}
|
|
101
|
+
if (result.status !== 0) {
|
|
102
|
+
// The library prints value-free messages; only their ERR_* code is kept.
|
|
103
|
+
const code = String(result.stderr || '').match(/\bERR_[A-Z_]+\b/);
|
|
104
|
+
throw new SanitizationError(code ? code[0] : 'ERR_SANITIZER', subject);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sanitize a short text, such as the failing command quoted in an issue.
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
function sanitizeText(text, options = {}) {
|
|
114
|
+
const result = runSanitizer(
|
|
115
|
+
['redact', '-', ...OPTIONS],
|
|
116
|
+
{ ...options, input: text },
|
|
117
|
+
'Text'
|
|
118
|
+
);
|
|
119
|
+
return result.stdout.toString();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function countMarkers(filePath) {
|
|
123
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
124
|
+
const buffer = Buffer.alloc(BLOCK_SIZE);
|
|
125
|
+
let count = 0;
|
|
126
|
+
let carry = '';
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const length = fs.readSync(descriptor, buffer, 0, BLOCK_SIZE, null);
|
|
130
|
+
const text = carry + buffer.toString('latin1', 0, length);
|
|
131
|
+
let index = text.indexOf(MARKER);
|
|
132
|
+
let next = 0;
|
|
133
|
+
while (index !== -1) {
|
|
134
|
+
count++;
|
|
135
|
+
next = index + MARKER.length;
|
|
136
|
+
index = text.indexOf(MARKER, next);
|
|
160
137
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
offset = match.end;
|
|
164
|
-
if (offset === input.length && match.continuation && !final) {
|
|
165
|
-
this.continuation = match.continuation;
|
|
166
|
-
break;
|
|
138
|
+
if (length === 0) {
|
|
139
|
+
return count;
|
|
167
140
|
}
|
|
141
|
+
carry = text.slice(Math.max(next, text.length - MARKER.length + 1));
|
|
168
142
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
this.pending = input.slice(end);
|
|
172
|
-
return Buffer.from(output.join(''), 'latin1');
|
|
143
|
+
} finally {
|
|
144
|
+
fs.closeSync(descriptor);
|
|
173
145
|
}
|
|
174
146
|
}
|
|
175
147
|
|
|
176
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Create a private sanitized copy of a log. The source is never modified;
|
|
150
|
+
* any failure removes the copy and throws a value-free SanitizationError.
|
|
151
|
+
* @returns {{path: string, cleanup: Function}}
|
|
152
|
+
*/
|
|
177
153
|
function sanitizeLogToTemp(sourcePath, options = {}) {
|
|
178
154
|
let directory;
|
|
179
|
-
let
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const sanitizer = new StreamingSanitizer(options.env || process.env);
|
|
185
|
-
stage = 'source';
|
|
186
|
-
source = fs.openSync(sourcePath, 'r');
|
|
187
|
-
if (!fs.fstatSync(source).isFile()) {
|
|
188
|
-
throw new Error('Source must be a regular file.');
|
|
155
|
+
let code = 'ERR_PRIVATE_FILE';
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
if (directory) {
|
|
158
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
189
159
|
}
|
|
190
|
-
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
191
162
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
|
|
192
|
-
|
|
163
|
+
if (process.platform !== 'win32') {
|
|
164
|
+
fs.chmodSync(directory, 0o700);
|
|
165
|
+
}
|
|
193
166
|
const target = path.join(directory, 'execution.log');
|
|
194
|
-
|
|
195
|
-
fs.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
while (offset < output.length) {
|
|
202
|
-
const written = fs.writeSync(
|
|
203
|
-
destination,
|
|
204
|
-
output,
|
|
205
|
-
offset,
|
|
206
|
-
output.length - offset
|
|
207
|
-
);
|
|
208
|
-
if (!written) {
|
|
209
|
-
throw new Error('No progress writing sanitized log.');
|
|
210
|
-
}
|
|
211
|
-
offset += written;
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
for (;;) {
|
|
215
|
-
stage = 'read';
|
|
216
|
-
const length = fs.readSync(source, buffer, 0, buffer.length, null);
|
|
217
|
-
if (!length) {
|
|
218
|
-
break;
|
|
167
|
+
code = 'ERR_SOURCE';
|
|
168
|
+
const input = fs.openSync(sourcePath, 'r');
|
|
169
|
+
let bytes;
|
|
170
|
+
try {
|
|
171
|
+
const source = fs.fstatSync(input);
|
|
172
|
+
if (!source.isFile()) {
|
|
173
|
+
throw new SanitizationError('ERR_SOURCE');
|
|
219
174
|
}
|
|
220
|
-
bytes
|
|
221
|
-
|
|
222
|
-
|
|
175
|
+
bytes = source.size;
|
|
176
|
+
const size = Math.max(1, bytes);
|
|
177
|
+
code = 'ERR_PRIVATE_FILE';
|
|
178
|
+
runSanitizer(
|
|
179
|
+
[
|
|
180
|
+
'redact',
|
|
181
|
+
'-',
|
|
182
|
+
'--stream',
|
|
183
|
+
// A NUL byte in a terminal transcript must not block the upload.
|
|
184
|
+
'--allow-binary',
|
|
185
|
+
...OPTIONS,
|
|
186
|
+
'--max-bytes',
|
|
187
|
+
String(size),
|
|
188
|
+
'--output',
|
|
189
|
+
target,
|
|
190
|
+
],
|
|
191
|
+
{ env: options.env, stdio: [input, 'ignore', 'pipe'] }
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
fs.closeSync(input);
|
|
195
|
+
}
|
|
196
|
+
code = 'ERR_OUTPUT';
|
|
197
|
+
const status = fs.lstatSync(target);
|
|
198
|
+
if (
|
|
199
|
+
!status.isFile() ||
|
|
200
|
+
(process.platform !== 'win32' && (status.mode & 0o077) !== 0)
|
|
201
|
+
) {
|
|
202
|
+
throw new SanitizationError('ERR_OUTPUT');
|
|
223
203
|
}
|
|
224
|
-
write(sanitizer.push(Buffer.alloc(0), true));
|
|
225
|
-
stage = 'sync';
|
|
226
|
-
fs.fsyncSync(destination);
|
|
227
|
-
fs.closeSync(destination);
|
|
228
|
-
destination = undefined;
|
|
229
|
-
fs.closeSync(source);
|
|
230
|
-
source = undefined;
|
|
231
204
|
if (options.verbose) {
|
|
205
|
+
const redactions = countMarkers(target) - countMarkers(sourcePath);
|
|
232
206
|
console.log(
|
|
233
|
-
`Prepared private sanitized log: ${bytes} bytes scanned, ${
|
|
207
|
+
`Prepared private sanitized log: ${bytes} bytes scanned, ${Math.max(0, redactions)} redactions.`
|
|
234
208
|
);
|
|
235
209
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
210
|
+
return { path: target, cleanup };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
cleanup();
|
|
213
|
+
// Static messages keep paths and system error details out of the output.
|
|
214
|
+
const failure =
|
|
215
|
+
error instanceof SanitizationError ? error : new SanitizationError(code);
|
|
242
216
|
if (options.verbose) {
|
|
243
|
-
console.
|
|
244
|
-
}
|
|
245
|
-
// Do not echo error messages: paths and exception details can carry secrets.
|
|
246
|
-
throw new Error('Log sanitization failed; upload blocked.');
|
|
247
|
-
} finally {
|
|
248
|
-
for (const fd of [destination, source]) {
|
|
249
|
-
if (fd !== undefined) {
|
|
250
|
-
try {
|
|
251
|
-
fs.closeSync(fd);
|
|
252
|
-
} catch {
|
|
253
|
-
/* Best effort after failure. */
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (directory && !ready) {
|
|
258
|
-
try {
|
|
259
|
-
fs.rmSync(directory, { recursive: true, force: true });
|
|
260
|
-
} catch {
|
|
261
|
-
/* Upload remains blocked. */
|
|
262
|
-
}
|
|
217
|
+
console.error(failure.message);
|
|
263
218
|
}
|
|
219
|
+
throw failure;
|
|
264
220
|
}
|
|
265
221
|
}
|
|
266
222
|
|
|
267
|
-
module.exports = {
|
|
223
|
+
module.exports = {
|
|
224
|
+
BLOCK_SIZE,
|
|
225
|
+
SanitizationError,
|
|
226
|
+
resolveSanitizerCommand,
|
|
227
|
+
sanitizeLogToTemp,
|
|
228
|
+
sanitizeText,
|
|
229
|
+
};
|
package/src/lib/log-uploader.js
CHANGED
|
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { spawnSync } = require('child_process');
|
|
9
|
-
const { sanitizeLogToTemp } = require('./log-sanitizer');
|
|
9
|
+
const { SanitizationError, sanitizeLogToTemp } = require('./log-sanitizer');
|
|
10
10
|
|
|
11
11
|
function isExecutable(filePath) {
|
|
12
12
|
try {
|
|
@@ -174,11 +174,14 @@ function uploadLogPath(logPath, options = {}) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
return { success: true, exitCode: 0 };
|
|
177
|
-
} catch {
|
|
177
|
+
} catch (error) {
|
|
178
178
|
return {
|
|
179
179
|
success: false,
|
|
180
180
|
exitCode: 1,
|
|
181
|
-
error:
|
|
181
|
+
error:
|
|
182
|
+
error instanceof SanitizationError
|
|
183
|
+
? error.message
|
|
184
|
+
: 'Log preparation or upload failed; upload blocked.',
|
|
182
185
|
};
|
|
183
186
|
} finally {
|
|
184
187
|
if (prepared) {
|
|
@@ -504,6 +504,24 @@ describe('CI workflow invariants', () => {
|
|
|
504
504
|
assert.match(lint.body, /cargo metadata --locked/);
|
|
505
505
|
});
|
|
506
506
|
|
|
507
|
+
it('gates pull requests on every dependency being its latest release', () => {
|
|
508
|
+
// Obsolete dependencies, sensitive-data-sanitizer among them, stayed in
|
|
509
|
+
// use unnoticed (issue #253).
|
|
510
|
+
const jsJobs = parseJobs(readWorkflow('js.yml'));
|
|
511
|
+
const freshness = jsJobs.find((job) => job.name === 'dependency-freshness');
|
|
512
|
+
assert.ok(freshness, 'js.yml has no dependency-freshness job');
|
|
513
|
+
assert.match(
|
|
514
|
+
freshness.body,
|
|
515
|
+
/scripts\/check-dependency-freshness\.mjs --lockfiles\n/
|
|
516
|
+
);
|
|
517
|
+
const status = jsJobs.find((job) => job.name === 'pipeline-status');
|
|
518
|
+
assert.ok(parseNeeds(status.body).includes('dependency-freshness'));
|
|
519
|
+
const lint = parseJobs(readWorkflow('rust.yml')).find(
|
|
520
|
+
(job) => job.name === 'lint'
|
|
521
|
+
);
|
|
522
|
+
assert.match(lint.body, /cargo update --dry-run/);
|
|
523
|
+
});
|
|
524
|
+
|
|
507
525
|
it('reports a failed Rust test as a failed test, not low coverage', () => {
|
|
508
526
|
// Without pipefail a failing test reached the threshold check as an empty
|
|
509
527
|
// value: "Coverage % is below the 50% threshold" (issue #234).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #253: command-stream 2.x returns completed `stdout` and `stderr` as
|
|
3
|
+
* readable stream snapshots instead of strings. `--use-command-stream` must
|
|
4
|
+
* still write the command's output to its log file.
|
|
5
|
+
*/
|
|
6
|
+
const { describe, it, expect } = require('bun:test');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { pathToFileURL } = require('url');
|
|
12
|
+
|
|
13
|
+
const CLI = path.join(__dirname, '../src/bin/cli.js');
|
|
14
|
+
|
|
15
|
+
describe('--use-command-stream log (#253)', () => {
|
|
16
|
+
it('loads execution without compatibility and terminal modules', () => {
|
|
17
|
+
const wrapper = pathToFileURL(
|
|
18
|
+
path.join(__dirname, '../src/lib/command-stream.js')
|
|
19
|
+
).href;
|
|
20
|
+
const result = spawnSync(
|
|
21
|
+
'node',
|
|
22
|
+
[
|
|
23
|
+
'--input-type=module',
|
|
24
|
+
'-e',
|
|
25
|
+
`import { registerHooks } from 'node:module';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
const extras = [];
|
|
28
|
+
registerHooks({ load(url, context, next) {
|
|
29
|
+
if (/command-stream\\/src\\/(shelljs|terminal|rust-repl)/.test(url)) extras.push(url);
|
|
30
|
+
return next(url, context);
|
|
31
|
+
}});
|
|
32
|
+
const { default: wrapper } = await import(${JSON.stringify(wrapper)});
|
|
33
|
+
const core = await wrapper.getCommandStream();
|
|
34
|
+
assert.equal(typeof core.$, 'function');
|
|
35
|
+
assert.equal(typeof core.ProcessRunner, 'function');
|
|
36
|
+
assert.deepEqual(extras, []);`,
|
|
37
|
+
],
|
|
38
|
+
{ encoding: 'utf8', timeout: 30000 }
|
|
39
|
+
);
|
|
40
|
+
expect(result.error).toBeUndefined();
|
|
41
|
+
expect(result.stderr).toBe('');
|
|
42
|
+
expect(result.status).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.skipIf(process.platform === 'win32')(
|
|
46
|
+
'writes stdout and stderr to the log file',
|
|
47
|
+
() => {
|
|
48
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-253-'));
|
|
49
|
+
try {
|
|
50
|
+
const result = spawnSync(
|
|
51
|
+
process.execPath,
|
|
52
|
+
[
|
|
53
|
+
CLI,
|
|
54
|
+
'--use-command-stream',
|
|
55
|
+
'--',
|
|
56
|
+
'sh',
|
|
57
|
+
'-c',
|
|
58
|
+
'echo out-253; echo err-253 >&2',
|
|
59
|
+
],
|
|
60
|
+
{
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
START_APP_FOLDER: path.join(root, 'app'),
|
|
64
|
+
START_LOG_DIR: path.join(root, 'logs'),
|
|
65
|
+
START_DISABLE_AUTO_ISSUE: '1',
|
|
66
|
+
START_DISABLE_LOG_UPLOAD: '1',
|
|
67
|
+
},
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
timeout: 30000,
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
expect(result.status).toBe(0);
|
|
73
|
+
expect(result.stderr).not.toContain('Could not append log file');
|
|
74
|
+
const logDir = path.join(root, 'logs', 'direct');
|
|
75
|
+
const [logName] = fs.readdirSync(logDir);
|
|
76
|
+
const log = fs.readFileSync(path.join(logDir, logName), 'utf8');
|
|
77
|
+
expect(log).toContain('out-253\n');
|
|
78
|
+
expect(log).toContain('err-253\n');
|
|
79
|
+
} finally {
|
|
80
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
40000
|
|
84
|
+
);
|
|
85
|
+
});
|