Skip to content

Commit 8be5f19

Browse files
authored
Merge pull request #2093 from lidge-jun/dev
promote: dev to main for v2.26.0
2 parents e97fb26 + f2e5e76 commit 8be5f19

410 files changed

Lines changed: 15674 additions & 1320 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/copilot-workflows.test.cjs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ const fs = require('node:fs');
44
const path = require('node:path');
55

66
const ROOT = path.resolve(__dirname, '..', '..');
7-
const AI_ACTION = 'actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222';
8-
const CLI_INSTALL = 'npm install --global @github/copilot@1.0.74';
7+
const COPILOT_RUNNER = 'node .github/scripts/run-copilot-inference.cjs';
8+
const CLI_INSTALL = 'bash .github/scripts/install-copilot-cli.sh';
99
const SETUP_NODE = 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e';
10+
const TOKEN_FALLBACK = 'COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN || github.token }}';
11+
const COPILOT_VERSION = 'COPILOT_VERSION="v1.0.74"';
12+
const COPILOT_SHA256 = 'COPILOT_SHA256="4a708b0a1cbaef4c2ca5c546a622f887a3b70e8a0432bc3cee0d386704816650"';
1013

1114
function readWorkflow(name) {
1215
return fs.readFileSync(path.join(ROOT, '.github', 'workflows', name), 'utf8');
@@ -16,24 +19,30 @@ function count(text, fragment) {
1619
return text.split(fragment).length - 1;
1720
}
1821

19-
test('issue automation uses pinned Copilot inference without tool access', () => {
22+
test('issue automation streams prompts through the digest-pinned Copilot CLI without tool access', () => {
2023
const quality = readWorkflow('enforce-issue-quality.yml');
2124
const triage = readWorkflow('issue-triage.yml');
2225
const combined = quality + '\n' + triage;
26+
const installer = fs.readFileSync(path.join(ROOT, '.github', 'scripts', 'install-copilot-cli.sh'), 'utf8');
2327

24-
assert.equal(count(quality, AI_ACTION), 2);
25-
assert.equal(count(triage, AI_ACTION), 1);
28+
assert.equal(count(quality, COPILOT_RUNNER), 2);
29+
assert.equal(count(triage, COPILOT_RUNNER), 1);
2630
assert.equal(count(quality, SETUP_NODE), 2);
2731
assert.equal(count(triage, SETUP_NODE), 1);
2832
assert.equal(count(quality, CLI_INSTALL), 2);
2933
assert.equal(count(triage, CLI_INSTALL), 1);
3034
assert.equal(count(quality, 'copilot-requests: write'), 2);
3135
assert.equal(count(triage, 'copilot-requests: write'), 1);
32-
assert.equal(count(quality, 'GITHUB_TOKEN: ${{ github.token }}'), 2);
33-
assert.equal(count(triage, 'GITHUB_TOKEN: ${{ github.token }}'), 1);
34-
assert.equal(count(quality, 'model: ""'), 2);
35-
assert.equal(count(triage, 'model: ""'), 1);
36+
assert.equal(count(quality, TOKEN_FALLBACK), 2);
37+
assert.equal(count(triage, TOKEN_FALLBACK), 1);
3638

39+
assert.match(installer, new RegExp(COPILOT_VERSION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
40+
assert.match(installer, new RegExp(COPILOT_SHA256.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
41+
assert.match(installer, /sha256sum --check --status/);
42+
assert.match(installer, /releases\/download\/\$\{COPILOT_VERSION\}\/\$\{COPILOT_ASSET\}/);
43+
44+
assert.doesNotMatch(combined, /npm install --global @github\/copilot/);
45+
assert.doesNotMatch(combined, /actions\/ai-inference@/);
3746
assert.doesNotMatch(combined, /\bmodels:\s*read\b/);
3847
assert.doesNotMatch(combined, /max-tokens:/);
3948
assert.doesNotMatch(combined, /copilot-allow-tools:/);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
COPILOT_VERSION="v1.0.74"
5+
COPILOT_ASSET="copilot-linux-x64.tar.gz"
6+
COPILOT_SHA256="4a708b0a1cbaef4c2ca5c546a622f887a3b70e8a0432bc3cee0d386704816650"
7+
COPILOT_URL="https://github.com/github/copilot-cli/releases/download/${COPILOT_VERSION}/${COPILOT_ASSET}"
8+
9+
install_root="${RUNNER_TEMP:?RUNNER_TEMP is required}/copilot-cli-${COPILOT_VERSION}"
10+
archive="${install_root}/${COPILOT_ASSET}"
11+
bin_dir="${install_root}/bin"
12+
13+
rm -rf -- "$install_root"
14+
mkdir -p "$bin_dir"
15+
16+
curl \
17+
--proto '=https' \
18+
--tlsv1.2 \
19+
--fail \
20+
--silent \
21+
--show-error \
22+
--location \
23+
--retry 3 \
24+
"$COPILOT_URL" \
25+
--output "$archive"
26+
27+
printf '%s %s\n' "$COPILOT_SHA256" "$archive" | sha256sum --check --status
28+
29+
tar -xzf "$archive" -C "$bin_dir"
30+
chmod +x "$bin_dir/copilot"
31+
"$bin_dir/copilot" --version
32+
33+
printf '%s\n' "$bin_dir" >> "${GITHUB_PATH:?GITHUB_PATH is required}"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
const fs = require('node:fs');
2+
const crypto = require('node:crypto');
3+
const { spawnSync } = require('node:child_process');
4+
5+
function fail(message, code = 1) {
6+
process.stderr.write(`${message}\n`);
7+
process.exit(code);
8+
}
9+
10+
const promptPath = process.argv[2];
11+
let userPrompt;
12+
try {
13+
userPrompt = promptPath
14+
? fs.readFileSync(promptPath, 'utf8')
15+
: fs.readFileSync(0, 'utf8');
16+
} catch (error) {
17+
fail(`Unable to read Copilot prompt: ${error instanceof Error ? error.message : String(error)}`);
18+
}
19+
20+
const systemPrompt = String(process.env.COPILOT_SYSTEM_PROMPT || '').trim();
21+
const prompt = systemPrompt
22+
? `${systemPrompt}\n\n${userPrompt}`
23+
: userPrompt;
24+
25+
const rawTimeout = Number(process.env.COPILOT_TIMEOUT_MS || 120_000);
26+
const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0
27+
? Math.floor(rawTimeout)
28+
: 120_000;
29+
30+
const args = [
31+
'-s',
32+
'--no-ask-user',
33+
'--no-custom-instructions',
34+
'--no-auto-update',
35+
];
36+
37+
const copilotEnv = { ...process.env };
38+
if (copilotEnv.COPILOT_GITHUB_TOKEN) {
39+
// Copilot CLI v1.0.74 authenticates from GH_TOKEN or GITHUB_TOKEN.
40+
copilotEnv.GITHUB_TOKEN = copilotEnv.COPILOT_GITHUB_TOKEN;
41+
}
42+
43+
const result = spawnSync('copilot', args, {
44+
input: prompt,
45+
encoding: 'utf8',
46+
env: copilotEnv,
47+
maxBuffer: 16 * 1024 * 1024,
48+
timeout,
49+
killSignal: 'SIGKILL',
50+
});
51+
52+
if (result.stderr) {
53+
process.stderr.write(result.stderr);
54+
}
55+
56+
if (result.error) {
57+
const errorCode = result.error.code || 'spawn_error';
58+
const signal = result.signal || 'none';
59+
fail(`Copilot CLI execution failed (${errorCode}; signal=${signal}): ${result.error.message}`);
60+
}
61+
62+
if (result.status !== 0) {
63+
process.exit(Number.isInteger(result.status) ? result.status : 1);
64+
}
65+
66+
const outputFile = process.env.GITHUB_OUTPUT;
67+
if (!outputFile) {
68+
fail('GITHUB_OUTPUT is not set.');
69+
}
70+
71+
const response = String(result.stdout || '').trimEnd();
72+
const delimiter = `COPILOT_RESPONSE_${crypto.randomBytes(12).toString('hex')}`;
73+
fs.appendFileSync(outputFile, `response<<${delimiter}\n${response}\n${delimiter}\n`);
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const fs = require('node:fs');
4+
const os = require('node:os');
5+
const path = require('node:path');
6+
const { spawnSync } = require('node:child_process');
7+
8+
const RUNNER = path.join(__dirname, 'run-copilot-inference.cjs');
9+
10+
function makeFakeCopilot(source) {
11+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-copilot-'));
12+
const file = path.join(dir, 'copilot');
13+
fs.writeFileSync(file, `#!/usr/bin/env node\n${source}\n`, { mode: 0o755 });
14+
return { dir, file };
15+
}
16+
17+
function outputValue(file, key) {
18+
const text = fs.readFileSync(file, 'utf8');
19+
const match = text.match(new RegExp(`${key}<<([^\\n]+)\\n([\\s\\S]*?)\\n\\1(?:\\n|$)`));
20+
assert.ok(match, `missing ${key} output in ${text}`);
21+
return match[2];
22+
}
23+
24+
test('streams a large prompt over stdin and maps the Copilot token to GITHUB_TOKEN', () => {
25+
const fake = makeFakeCopilot(`
26+
const fs = require('node:fs');
27+
const input = fs.readFileSync(0, 'utf8');
28+
const argvBytes = Buffer.byteLength(process.argv.slice(2).join(' '));
29+
if (argvBytes > 8192) {
30+
console.error('prompt leaked into argv');
31+
process.exit(91);
32+
}
33+
process.stdout.write(JSON.stringify({
34+
inputBytes: Buffer.byteLength(input),
35+
argv: process.argv.slice(2),
36+
githubToken: process.env.GITHUB_TOKEN || '',
37+
}));
38+
`);
39+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-runner-test-'));
40+
const promptFile = path.join(dir, 'prompt.txt');
41+
const outputFile = path.join(dir, 'output.txt');
42+
const prompt = 'x'.repeat(512 * 1024);
43+
fs.writeFileSync(promptFile, prompt);
44+
fs.writeFileSync(outputFile, '');
45+
46+
const result = spawnSync(process.execPath, [RUNNER, promptFile], {
47+
encoding: 'utf8',
48+
env: {
49+
...process.env,
50+
PATH: `${fake.dir}${path.delimiter}${process.env.PATH}`,
51+
GITHUB_OUTPUT: outputFile,
52+
COPILOT_SYSTEM_PROMPT: 'system instruction',
53+
COPILOT_GITHUB_TOKEN: 'test-token',
54+
GITHUB_TOKEN: '',
55+
},
56+
});
57+
58+
assert.equal(result.status, 0, result.stderr);
59+
const response = JSON.parse(outputValue(outputFile, 'response'));
60+
assert.ok(response.inputBytes > Buffer.byteLength(prompt));
61+
assert.deepEqual(response.argv, ['-s', '--no-ask-user', '--no-custom-instructions', '--no-auto-update']);
62+
assert.equal(response.githubToken, 'test-token');
63+
});
64+
65+
test('surfaces Copilot stderr and preserves a non-zero exit code', () => {
66+
const fake = makeFakeCopilot(`
67+
process.stdin.resume();
68+
process.stdin.on('end', () => {
69+
console.error('copilot auth failed: test diagnostic');
70+
process.exit(7);
71+
});
72+
`);
73+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-runner-test-'));
74+
const promptFile = path.join(dir, 'prompt.txt');
75+
const outputFile = path.join(dir, 'output.txt');
76+
fs.writeFileSync(promptFile, 'hello');
77+
fs.writeFileSync(outputFile, '');
78+
79+
const result = spawnSync(process.execPath, [RUNNER, promptFile], {
80+
encoding: 'utf8',
81+
env: {
82+
...process.env,
83+
PATH: `${fake.dir}${path.delimiter}${process.env.PATH}`,
84+
GITHUB_OUTPUT: outputFile,
85+
COPILOT_SYSTEM_PROMPT: 'system instruction',
86+
COPILOT_GITHUB_TOKEN: 'test-token',
87+
},
88+
});
89+
90+
assert.equal(result.status, 7);
91+
assert.match(result.stderr, /copilot auth failed: test diagnostic/);
92+
assert.equal(fs.readFileSync(outputFile, 'utf8'), '');
93+
});
94+
95+
test('kills a hung Copilot process at the configured timeout', () => {
96+
const fake = makeFakeCopilot(`
97+
process.stderr.write('copilot started\\n');
98+
setInterval(() => {}, 1000);
99+
`);
100+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-runner-test-'));
101+
const promptFile = path.join(dir, 'prompt.txt');
102+
const outputFile = path.join(dir, 'output.txt');
103+
fs.writeFileSync(promptFile, 'hello');
104+
fs.writeFileSync(outputFile, '');
105+
106+
const result = spawnSync(process.execPath, [RUNNER, promptFile], {
107+
encoding: 'utf8',
108+
timeout: 5000,
109+
env: {
110+
...process.env,
111+
PATH: `${fake.dir}${path.delimiter}${process.env.PATH}`,
112+
GITHUB_OUTPUT: outputFile,
113+
COPILOT_SYSTEM_PROMPT: 'system instruction',
114+
COPILOT_GITHUB_TOKEN: 'test-token',
115+
COPILOT_TIMEOUT_MS: '75',
116+
},
117+
});
118+
119+
assert.notEqual(result.status, 0);
120+
assert.match(result.stderr, /ETIMEDOUT/);
121+
assert.match(result.stderr, /SIGKILL/);
122+
assert.equal(fs.readFileSync(outputFile, 'utf8'), '');
123+
});

0 commit comments

Comments
 (0)