feat: 前置驗證納入 git push 認證檢查
git push 走 askpass + HTTP 認證,與 Gitea REST API 是兩套機制,API token 有效不代表 push 能用(曾出現 askpass 無法執行、could not read Username 而 push 失敗)。新增 git.js verifyRemoteAccess() 以相同 askpass + remote URL 跑唯讀 git ls-remote,preflight 呼叫並在失敗時 exit 1,提前攔下設定問題。 新增 git.test.js 對 verifyRemoteAccess 的測試(成功、失敗不丟例外、token 不外洩、askpass 清理)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
@@ -258,6 +258,24 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||||
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,
|
||||
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
||||
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
||||
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
||||
*/
|
||||
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
||||
const run = makeRunner(_spawnSync);
|
||||
try {
|
||||
return withAskpass(workspace, credEnv => {
|
||||
run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv);
|
||||
return { ok: true };
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone PR head branch to workspace/repo (idempotent)
|
||||
*/
|
||||
|
||||
+45
-1
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
||||
import { commitAndPush, cloneRepo, verifyRemoteAccess, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
|
||||
|
||||
// --- helpers ---
|
||||
function makeTmpWorkspace() {
|
||||
@@ -334,3 +334,47 @@ describe('cloneRepo', () => {
|
||||
assert.equal(isBotAutoCommit(workspace, spawn), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyRemoteAccess', () => {
|
||||
let workspace;
|
||||
before(() => { workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'git-lsremote-')); });
|
||||
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
|
||||
|
||||
it('runs git ls-remote with the askpass credential env and reports ok on success', () => {
|
||||
const calls = [];
|
||||
const spawn = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return { status: 0, stdout: 'abc123\tHEAD', stderr: '', error: null };
|
||||
};
|
||||
const result = verifyRemoteAccess(workspace, spawn);
|
||||
assert.deepEqual(result, { ok: true });
|
||||
const lsRemote = calls.find(c => c.args[0] === 'ls-remote');
|
||||
assert.ok(lsRemote, 'expected git ls-remote to run');
|
||||
assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote');
|
||||
});
|
||||
|
||||
it('does not leak the token in ls-remote args', () => {
|
||||
const calls = [];
|
||||
const spawn = (cmd, args, opts) => {
|
||||
calls.push({ args });
|
||||
return { status: 0, stdout: '', stderr: '', error: null };
|
||||
};
|
||||
verifyRemoteAccess(workspace, spawn);
|
||||
for (const { args } of calls) {
|
||||
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports failure (not throw) when git ls-remote fails', () => {
|
||||
const spawn = () => ({ status: 128, stdout: '', stderr: 'fatal: could not read Username', error: null });
|
||||
const result = verifyRemoteAccess(workspace, spawn);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /could not read Username/);
|
||||
});
|
||||
|
||||
it('cleans up the askpass script after running', () => {
|
||||
verifyRemoteAccess(workspace, () => ({ status: 0, stdout: '', stderr: '', error: null }));
|
||||
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
|
||||
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ async function main() {
|
||||
line(`repo=${GITEA_REPOSITORY} PR=#${PR_NUMBER}`);
|
||||
line(`${PR_HEAD_BRANCH} -> ${PR_BASE_BRANCH}`);
|
||||
|
||||
if (!(await runPreflight())) {
|
||||
if (!(await runPreflight(WORKSPACE))) {
|
||||
error('前置驗證未通過,終止流程');
|
||||
section('Pipeline 結束');
|
||||
process.exit(1);
|
||||
|
||||
+9
-1
@@ -9,6 +9,7 @@ import {
|
||||
PR_NUMBER,
|
||||
getLLMConfig,
|
||||
} from './config.js';
|
||||
import { verifyRemoteAccess } from './git.js';
|
||||
import { step, line, ok, error } from './log.js';
|
||||
|
||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||
@@ -92,7 +93,7 @@ export async function verifyLLM() {
|
||||
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
|
||||
* 僅做唯讀的認證/連線確認,不發布任何 comment。
|
||||
*/
|
||||
export async function runPreflight() {
|
||||
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace') {
|
||||
step('Step1.5', '前置驗證(驗證相關設定)');
|
||||
|
||||
const env = checkRequiredEnv();
|
||||
@@ -117,6 +118,13 @@ export async function runPreflight() {
|
||||
if (comment.skipped) line('未提供 GITEA_COMMENT_TOKEN,comment 將沿用 GITEA_TOKEN');
|
||||
else ok('GITEA_COMMENT_TOKEN 可用');
|
||||
|
||||
const remote = verifyRemoteAccess(workspace);
|
||||
if (!remote.ok) {
|
||||
error(`git push 認證/連線驗證失敗(ls-remote): ${remote.error}`);
|
||||
return false;
|
||||
}
|
||||
ok('git remote 認證可用(ls-remote 成功)');
|
||||
|
||||
const llm = await verifyLLM();
|
||||
if (!llm.ok) {
|
||||
error(`LLM 驗證失敗: ${llm.error}`);
|
||||
|
||||
Reference in New Issue
Block a user