fix(ci): build push and run links from GITHUB_SERVER_URL (#766)

night-red.mjs fell back to `https://github.com/<repo>/actions/runs/<id>` when
the API did not return html_url, and five publishing steps pushed to
`https://x-access-token:$TOKEN@github.com/<repo>`. On github.com nothing
breaks today; on any other server (GHES) the links and pushes would point to
the wrong host while GITHUB_API_URL is already honoured (#751).

- ci-proof.mjs: githubServerUrl(env) - GITHUB_SERVER_URL without a trailing
  slash, github.com when unset; night-red threads it as `server` into
  commentBody and nightRed, html_url from the API still wins.
- _beta-derived, _process (rebase and review document), _ship-review and
  release-review: `server="${GITHUB_SERVER_URL:-https://github.com}"` and
  push_url built from it; every token push uses "$push_url".

Tests: the night step executed on a non-standard server writes comment and
summary links to that host; every token push in every workflow takes its host
from GITHUB_SERVER_URL; the four publishing steps executed with
GITHUB_SERVER_URL=https://ghe.example.test push to that host, and to
github.com when it is empty. Checked: on the previous workflows and with the
hard-coded fallback restored in night-red these tests are red.

Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-06 23:05:58 +00:00
committed by claude[bot]
parent 0bf68d6dd2
commit b44bb82a3a
9 changed files with 137 additions and 27 deletions
+4 -1
View File
@@ -28,7 +28,10 @@ test('#697 бот: только по кнопке, прав на запись у
assert.doesNotMatch(text, /contents: write/);
}
const commit = step('Коммит в dev');
assert.match(commit, /git push -q "https:\/\/x-access-token:\$TOKEN@github\.com\/\$\{\{ github\.repository \}\}" HEAD:dev/);
// #766: хост — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
assert.match(commit, /push_url="\$\{server%%:\/\/\*\}:\/\/x-access-token:\$TOKEN@\$\{server#\*:\/\/\}\/\$\{\{ github\.repository \}\}"/);
assert.match(commit, /git push -q "\$push_url" HEAD:dev/);
assert.equal((commit.match(/git push/g) || []).length, 1, 'один push');
assert.doesNotMatch(commit, /--force/, 'ушедший dev — перезапуск, а не перезапись');
});
+42 -3
View File
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { buildCiProof, githubServerUrl } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
import { findStep, stepCommand } from './helpers/workflow-step.mjs';
import {
@@ -386,7 +386,7 @@ function fakeGh(root, issues) {
}
/** Шаг ночи в рабочей копии `cwd` с `scripts/` из этого дерева; асинхронно — сервер живёт в этом процессе. */
async function runNightStep(t, { cwd, items, issues = {}, fail = false, scripts = SCRIPTS }) {
async function runNightStep(t, { cwd, items, issues = {}, fail = false, scripts = SCRIPTS, env = {} }) {
const root = mkdtempSync(join(tmpdir(), 'hp-736-step-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const api = await actionsServer(t, items, { fail });
@@ -404,7 +404,7 @@ async function runNightStep(t, { cwd, items, issues = {}, fail = false, scripts
env: {
...ENV, PATH: `${gh.bin}:${process.env.PATH}`, REPO, RED_RUN: '105',
ACTIONS_TOKEN: 'actions-token', GH_TOKEN: 'process-token', GITHUB_API_URL: api.base,
GITHUB_STEP_SUMMARY: files.summary, FAKE_LOG: files.log, FAKE_ISSUES: gh.data,
GITHUB_STEP_SUMMARY: files.summary, FAKE_LOG: files.log, FAKE_ISSUES: gh.data, ...env,
},
});
let stdout = '';
@@ -492,3 +492,42 @@ test('#736 К6 на настоящем bash: сбой API или упавший
assert.match(crashed.stdout, /^::warning::красная ночь \(#736\): скрипт упал — комментарии не написаны/m);
assert.match(crashed.summary, /- Красная ночь \(#736\): скрипт упал/);
});
// #766: ссылка на прогон без `html_url` строится от сервера раннера
// (`GITHUB_SERVER_URL`), а не от зашитого https://github.com.
test('#766: сервер ссылок — GITHUB_SERVER_URL без хвостового /, по умолчанию github.com; html_url главнее', () => {
assert.equal(githubServerUrl({}), 'https://github.com');
assert.equal(githubServerUrl({ GITHUB_SERVER_URL: 'https://ghe.example.test/' }), 'https://ghe.example.test');
const R = 'd'.repeat(40);
const G = 'a'.repeat(40);
const body = commentBody({
repo: REPO, red: { id: 5, head_sha: R }, green: { id: 4, head_sha: G, html_url: 'https://api.example/runs/4' },
commits: [{ sha: '1'.repeat(40), subject: 'one' }], server: 'https://ghe.example.test',
});
assert.ok(body.includes(`красный — https://ghe.example.test/${REPO}/actions/runs/5 `), body);
assert.ok(body.includes('Последняя зелёная ночь — https://api.example/runs/4 '), 'ссылка API не переписывается');
assert.doesNotMatch(body, /github\.com/);
});
test('#766 на настоящем bash: шаг ночи на нестандартном сервере — ссылки прогонов без html_url от GITHUB_SERVER_URL', async (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
const { cwd, sha } = history(t);
const bare = ({ run: { html_url: _dropped, ...run }, context }) => ({ run, context });
const items = [
validateRun({ id: 105, sha: sha.R, at: day(24), conclusion: 'failure' }),
validateRun({ id: 104, sha: sha.c2, at: day(22), full: false }),
validateRun({ id: 102, sha: sha.G, at: day(20) }),
].map(bare);
const run = await runNightStep(t, {
cwd, items, env: { GITHUB_SERVER_URL: 'https://ghe.example.test/' },
issues: { 1: { state: 'OPEN', comments: [] }, 2: { state: 'OPEN', comments: [] }, 5: { state: 'OPEN', comments: [] }, 8: { state: 'OPEN', comments: [] } },
});
assert.equal(run.status, 0, run.stderr);
assert.doesNotMatch(run.stdout, /::warning::/, run.stdout);
const server = `https://ghe.example.test/${REPO}/actions/runs`;
assert.ok(run.comment(1).includes(`красный — ${server}/105 `), run.comment(1));
assert.ok(run.comment(1).includes(`Последняя зелёная ночь — ${server}/102 `), run.comment(1));
assert.ok(run.summary.includes(`- Красный прогон: ${server}/105 `), run.summary);
assert.ok(run.summary.includes(`- Отсеян зелёный ${server}/104 `), run.summary);
assert.doesNotMatch(run.comment(1) + run.summary, /github\.com/);
});
+52 -6
View File
@@ -2,14 +2,14 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PUSH_REFUSAL, classifyPushRefusal, refusalSummary } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
import { findStep, runStep } from './helpers/workflow-step.mjs';
import { findStep, runStep, workflowSteps } from './helpers/workflow-step.mjs';
// #723: два шага публикуют коммит и прежде любой отказ push считали сдвигом
// ветки — документ ревью релиза в `dev` (release-review.yml, три попытки) и
@@ -207,7 +207,7 @@ const noSecrets = (r) => {
const TAG = 'v1.78.0';
const RELEASE_DOC = `docs/reviews/RELEASE-REVIEW-${TAG}.md`;
function runRelease(box) {
function runRelease(box, extra = {}) {
const dir = join(box.temp, 'release-review-result');
mkdirSync(dir);
const files = {
@@ -219,7 +219,7 @@ function runRelease(box) {
.map(([name, text]) => `${createHash('sha256').update(text).digest('hex')} ${name}\n`).join(''));
return box.run(RELEASE_STEP(), {
TAG, DOC: RELEASE_DOC, CANDIDATE: 'c'.repeat(40), BASE: 'v1.77.0', ISSUES: '701,702',
RUN_URL: 'https://github.com/o/r/actions/runs/42',
RUN_URL: 'https://github.com/o/r/actions/runs/42', ...extra,
});
}
@@ -631,11 +631,11 @@ function derivedSandbox(t) {
}
/** Шаг съёмки изменил отпечаток; коммит и push — шагом как есть. */
function runDerived(box) {
function runDerived(box, extra = {}) {
writeFileSync(join(box.work, 'docs', 'images', 'screenshots.json'), '{"fingerprint":"new"}\n');
return box.run(DERIVED_STEP(), {
TAG: BETA, DOCS_CHANGED: 'true', DOCS_EXPECT: '', GOLDEN_CHANGED: '', GOLDEN_URL: '',
GOLDEN_EXPECT_CHANGE: '', GOLDEN_EXPECT_NEW: '', RUN_URL: 'https://github.com/o/r/actions/runs/44', HP_PREPUSH_GATE: '0',
GOLDEN_EXPECT_CHANGE: '', GOLDEN_EXPECT_NEW: '', RUN_URL: 'https://github.com/o/r/actions/runs/44', HP_PREPUSH_GATE: '0', ...extra,
});
}
@@ -732,6 +732,52 @@ test('#730 AC3: тела _ship-review.yml и _beta-derived.yml — без heredo
assert.match(DERIVED_STEP().script, /в dev — проверить перед кандидатом беты\." >> "\$GITHUB_STEP_SUMMARY"\n*$/);
});
// ---------- #766: хост push — сервер раннера, не зашитый github.com ----------
const SERVER_LINE = 'server="${GITHUB_SERVER_URL:-https://github.com}"';
const PUSH_URL_LINE = 'push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"';
test('#766: каждый push с токеном в workflow — на хост GITHUB_SERVER_URL с запасным github.com', () => {
const sites = [];
for (const name of readdirSync(WORKFLOWS).filter((file) => /\.ya?ml$/.test(file)).sort()) {
for (const step of workflowSteps(readFileSync(join(WORKFLOWS, name), 'utf8'), name)) {
if (!step.run || !step.run.includes('x-access-token')) continue;
const where = `${name}:${step.line}`;
sites.push(where);
assert.doesNotMatch(step.run, /@github\.com\b/, `${where}: хост push зашит`);
const lines = step.run.split('\n').map((line) => line.trim());
assert.ok(lines.includes(SERVER_LINE) && lines.includes(PUSH_URL_LINE), `${where}: адрес push — из GITHUB_SERVER_URL`);
assert.ok(lines.indexOf(SERVER_LINE) < lines.indexOf(PUSH_URL_LINE));
for (const push of step.run.match(/git push\b(?:[^\n]*\\\n)*[^\n]*/g) ?? []) {
assert.match(push, /"\$push_url"/, `${where}: ${push}`);
}
}
}
assert.deepEqual(sites.map((site) => site.split(':')[0]),
['_beta-derived.yml', '_process.yml', '_process.yml', '_ship-review.yml', 'release-review.yml'], sites.join(', '));
});
test('#766 на настоящем bash: шаги публикации пушат на хост GITHUB_SERVER_URL', (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const server = { GITHUB_SERVER_URL: 'https://ghe.example.test' };
// Транспорт подменён: адрес, с которым шаг позвал git push, — в журнале вызовов.
const hosts = (r) => r.calls.filter((call) => call.startsWith('push '))
.map((call) => /https:\/\/x-access-token:[^@\s]+@(\S+)/.exec(call)?.[1] ?? call);
const release = runRelease(sandbox(tempRoot(t, 'hp-766-release-')), server);
const docBox = sandbox(tempRoot(t, 'hp-766-doc-'));
taskBranch(docBox);
const doc = runReviewDoc(docBox, undefined, server);
const ship = runShip(sandbox(tempRoot(t, 'hp-766-ship-')), server);
const derived = runDerived(derivedSandbox(t), server);
for (const [label, r] of [['release-review.yml', release], ['_process.yml', doc], ['_ship-review.yml', ship], ['_beta-derived.yml', derived]]) {
assert.equal(r.status, 0, `${label}: ${r.stderr}${r.stdout}`);
assert.deepEqual(hosts(r), ['ghe.example.test/o/r'], label);
}
// Без GITHUB_SERVER_URL — прежний github.com.
const plain = runShip(sandbox(tempRoot(t, 'hp-766-plain-')), { GITHUB_SERVER_URL: '' });
assert.deepEqual(hosts(plain), ['github.com/o/r']);
});
test('#730: подписи сводки для публикации ship, производных артефактов и стража ребейза', () => {
const refusal = classifyPushRefusal(remoteRejected('dev', 'protected branch hook declined'));
assert.match(refusalSummary(refusal, { ref: 'dev', stage: 'ship-review' }), /\n\nДокумент пакетного ревью ship не опубликован в `dev`\./);