Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/scripts/smoke-test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
const fs = require('node:fs');

const checkName = 'Game server smoke test';
const runUrl = (context) => `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;

function requestPullNumber(context) {
if (context.eventName === 'workflow_dispatch') {
const value = context.payload.inputs?.pr_number;
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw new Error('pr_number must be a positive integer');
}
return Number(value);
}
const { issue, comment } = context.payload;
if (context.eventName === 'issue_comment' && issue?.pull_request &&
comment?.body.trim() === '/smoke-test' && comment.user.type === 'User') return issue.number;
return undefined;
}

async function isMaintainerRequest({ github, context }) {
// Check live repository permissions, not author_association (a contributor
// or organization member is not necessarily a maintainer). Check reruns too.
const requester = context.eventName === 'workflow_dispatch' ? context.actor : context.payload.comment?.user.login;
if (!requester) return false;
for (const username of new Set([requester, process.env.TRIGGERING_ACTOR || context.actor])) {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username });
if (data.permission !== 'admin' && data.permission !== 'maintain' && data.role_name !== 'maintain') {
return false;
}
}
return true;
}

async function authorize({ github, context, core }) {
const pullNumber = requestPullNumber(context);
if (!pullNumber) return;
if (!await isMaintainerRequest({ github, context })) {
core.info('Ignoring smoke-test request: maintain/admin permission required.');
return;
}
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: pullNumber });
if (pr.state !== 'open') return;
// Snapshot the latest head ONCE. All builds use this immutable SHA, not a
// mutable branch or refs/pull/N/head. Works for fork PRs as well.
const sha = pr.head.sha;
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('Invalid PR head SHA');
const details_url = runUrl(context);
const { data: check } = await github.rest.checks.create({
...context.repo,
name: checkName,
head_sha: sha,
status: 'in_progress',
details_url,
output: { title: 'Preparing smoke test', summary: `Building commit ${sha}. Benchmarks excluded.\n\n[Workflow run](${details_url})` },
});
const { data: reply } = await github.rest.issues.createComment({
...context.repo,
issue_number: pullNumber,
body: `### Game server smoke test\n\nRequested for commit ${sha}. Building, then waiting for the dedicated server.\n\n[Follow the run](${details_url})`,
});
core.setOutput('sha', sha);
core.setOutput('check_id', check.id);
core.setOutput('comment_id', reply.id);
core.setOutput('approved', 'true');
}

function readJson(path) {
// Artifacts and server files are untrusted data, never code or Markdown.
if (fs.statSync(path).size > 10 * 1024 * 1024) throw new Error('Report too large');
return JSON.parse(fs.readFileSync(path, 'utf8'));
}

function resultSummary(report) {
const keys = ['total', 'passed', 'failed', 'skipped'];
if (!keys.every((key) => Number.isSafeInteger(report[key]) && report[key] >= 0) ||
report.total !== report.passed + report.failed + report.skipped ||
!Array.isArray(report.errors) || !Array.isArray(report.tests) || report.tests.length !== report.total ||
['passed', 'failed', 'skipped'].some((outcome) => report.tests.filter((test) => test?.outcome === outcome).length !== report[outcome])) {
throw new Error('Invalid test report');
}
return {
success: report.passed > 0 && report.failed === 0 && report.errors.length === 0,
text: `${report.total} native tests: **${report.passed} passed**, **${report.failed} failed**, **${report.skipped} skipped**.\n\n` +
`Runner/cleanup errors: ${report.errors.length}. Benchmarks excluded.`,
};
}

function versionSummary(version) {
// Only render bounded, validated values. Raw steam.inf is also an artifact.
const patterns = {
PatchVersion: /^\d+(\.\d+)+$/,
ServerVersion: /^\d+$/,
ClientVersion: /^\d+$/,
SourceRevision: /^\d+$/,
VersionDate: /^[a-zA-Z0-9 ,/-]+$/,
VersionTime: /^[0-9:]+$/,
};
if (!Object.entries(patterns).every(([key, pattern]) =>
typeof version[key] === 'string' && version[key].length <= 100 && pattern.test(version[key]))) {
throw new Error('Invalid CS2 version');
}
return `**CS2:** ${version.PatchVersion} (server ${version.ServerVersion}, client ${version.ClientVersion})\n\n` +
`**Source revision:** ${version.SourceRevision} — ${version.VersionDate} ${version.VersionTime}`;
}

async function report({ github, context, core }) {
const env = process.env;
const jobs = [env.NATIVE_RESULT, env.MANAGED_RESULT, env.SMOKE_RESULT];
let success = jobs.every((result) => result === 'success');
let text = '';
try {
const result = resultSummary(readJson('smoke-results/smoke-results.json'));
success = success && result.success;
text += result.text;
} catch {
success = false;
text += 'No valid, complete native test report was received. See the workflow logs for build, deployment, or timeout errors.';
}
try {
text += `\n\n${versionSummary(readJson('smoke-results/server-version.json'))}`;
} catch {
success = false;
text += '\n\nCS2 version unavailable (the server may not have reached the test stage).';
}
const conclusion = success ? 'success' : jobs.includes('cancelled') ? 'cancelled' : 'failure';
const title = `${checkName}: ${conclusion === 'success' ? 'passed' : conclusion}`;
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: requestPullNumber(context) });
let summary = `**Tested commit:** ${env.TESTED_SHA}\n\n${text}\n\n` +
`Native build: ${env.NATIVE_RESULT}. Managed build/unit tests: ${env.MANAGED_RESULT}. Server run: ${env.SMOKE_RESULT}.\n\n` +
`[Logs and artifacts (JSON, Markdown, steam.inf, unit-test TRX)](${runUrl(context)})`;
if (pr.head.sha !== env.TESTED_SHA) summary += '\n\n⚠️ The PR has newer commits. This result does **not** cover the latest head; comment `/smoke-test` again to test it.';
await github.rest.checks.update({
...context.repo,
check_run_id: Number(env.CHECK_ID),
status: 'completed',
conclusion,
completed_at: new Date().toISOString(),
output: { title, summary },
});
await github.rest.issues.updateComment({
...context.repo,
comment_id: Number(env.COMMENT_ID),
body: `### ${title}\n\n${summary}`,
});
await core.summary.addRaw(`### ${title}\n\n${summary}`).write();
}

module.exports = { authorize, isMaintainerRequest, report, resultSummary, versionSummary };
197 changes: 197 additions & 0 deletions .github/scripts/smoke-test.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { authorize, isMaintainerRequest, report, resultSummary, versionSummary } = require('./smoke-test.cjs');

const sha = 'a'.repeat(40);
function request({ permission = 'write', role = 'maintain', body = '/smoke-test', state = 'open', pullRequest = true,
eventName = 'issue_comment', prNumber = '42' } = {}) {
const calls = [];
const outputs = {};
const context = {
repo: { owner: 'owner', repo: 'repo' }, actor: 'maintainer', serverUrl: 'https://github.com', runId: 123,
eventName,
payload: eventName === 'workflow_dispatch' ? { inputs: { pr_number: prNumber } } : {
issue: { number: 42, pull_request: pullRequest ? {} : undefined },
comment: { body, user: { login: 'maintainer', type: 'User' } },
},
};
const github = { rest: {
repos: { getCollaboratorPermissionLevel: async (args) => {
calls.push(['permission', args]);
return { data: { permission, role_name: role } };
} },
pulls: { get: async (args) => { calls.push(['pull', args]); return { data: { state, head: { sha } } }; } },
checks: { create: async (args) => { calls.push(['check', args]); return { data: { id: 1 } }; } },
issues: { createComment: async (args) => { calls.push(['comment', args]); return { data: { id: 2 } }; } },
} };
const core = { info() {}, setOutput: (key, value) => { outputs[key] = value; } };
return { github, context, core, calls, outputs };
}

test('maintainer command snapshots the latest head and attaches the check to it', async () => {
const input = request();
await authorize(input);
assert.equal(input.outputs.approved, 'true');
assert.equal(input.outputs.sha, sha);
assert.equal(input.calls.find(([name]) => name === 'check')[1].head_sha, sha);
});

test('admin can trigger', async () => {
const input = request({ permission: 'admin', role: 'admin' });
await authorize(input);
assert.equal(input.outputs.approved, 'true');
});

for (const role of ['write', 'read', 'triage', 'none']) {
test(`${role} permission cannot trigger a server run`, async () => {
const input = request({ permission: role, role });
await authorize(input);
assert.equal(input.outputs.approved, undefined);
assert.ok(input.calls.every(([name]) => name === 'permission'));
});
}

for (const options of [{ body: '/smoke-test something' }, { state: 'closed' }, { pullRequest: false }]) {
test(`ignores invalid request ${JSON.stringify(options)}`, async () => {
const input = request(options);
await authorize(input);
assert.equal(input.outputs.approved, undefined);
assert.ok(!input.calls.some(([name]) => name === 'check'));
});
}

test('an unauthorized rerun actor cannot reuse a maintainer request', async () => {
const input = request();
// context.actor is used when running locally without TRIGGERING_ACTOR.
input.context.actor = 'other';
input.github.rest.repos.getCollaboratorPermissionLevel = async ({ username }) => ({
data: { permission: 'write', role_name: username === 'maintainer' ? 'maintain' : 'write' },
});
await authorize(input);
assert.equal(input.outputs.approved, undefined);
});

test('manual dispatch targets the input PR and snapshots its latest head', async () => {
const input = request({ eventName: 'workflow_dispatch', prNumber: '123' });
await authorize(input);
assert.equal(input.outputs.approved, 'true');
assert.equal(input.outputs.sha, sha);
assert.equal(input.calls.find(([name]) => name === 'pull')[1].pull_number, 123);
assert.equal(input.calls.find(([name]) => name === 'comment')[1].issue_number, 123);
assert.equal(input.calls.find(([name]) => name === 'check')[1].head_sha, sha);
});

test('manual dispatch does not bypass maintainer permissions', async () => {
const input = request({ eventName: 'workflow_dispatch', role: 'write' });
await authorize(input);
assert.equal(input.outputs.approved, undefined);
assert.ok(input.calls.every(([name]) => name === 'permission'));
});

test('manual dispatch ignores closed PRs', async () => {
const input = request({ eventName: 'workflow_dispatch', state: 'closed' });
await authorize(input);
assert.equal(input.outputs.approved, undefined);
});

for (const prNumber of ['', '0', '-1', '1.5', '1e2', '123; echo unsafe', '9007199254740992', null]) {
test(`manual dispatch rejects invalid PR number: ${JSON.stringify(prNumber)}`, async () => {
const input = request({ eventName: 'workflow_dispatch', prNumber });
await assert.rejects(authorize(input), /positive integer/);
assert.equal(input.calls.length, 0);
});
}

test('deployment permission recheck rejects an unauthorized manual rerun actor', async () => {
const input = request({ eventName: 'workflow_dispatch' });
const previous = process.env.TRIGGERING_ACTOR;
try {
process.env.TRIGGERING_ACTOR = 'other';
input.github.rest.repos.getCollaboratorPermissionLevel = async ({ username }) => ({
data: { permission: 'write', role_name: username === 'maintainer' ? 'maintain' : 'write' },
});
assert.equal(await isMaintainerRequest(input), false);
} finally {
if (previous === undefined) delete process.env.TRIGGERING_ACTOR;
else process.env.TRIGGERING_ACTOR = previous;
}
});

const passing = { total: 2, passed: 1, failed: 0, skipped: 1, errors: [], tests: [{ outcome: 'passed' }, { outcome: 'skipped' }] };
test('summarizes passing results including skips', () => {
assert.equal(resultSummary(passing).success, true);
assert.match(resultSummary(passing).text, /1 passed/);
});
test('test failures, runner errors, zero tests and all-skipped runs cannot pass', () => {
assert.equal(resultSummary({ ...passing, errors: ['cleanup failure'] }).success, false);
assert.equal(resultSummary({ ...passing, passed: 0, failed: 1, tests: [{ outcome: 'failed' }, { outcome: 'skipped' }] }).success, false);
assert.equal(resultSummary({ total: 0, passed: 0, failed: 0, skipped: 0, errors: [], tests: [] }).success, false);
assert.equal(resultSummary({ total: 1, passed: 0, failed: 0, skipped: 1, errors: [], tests: [{ outcome: 'skipped' }] }).success, false);
});
test('rejects malformed or inconsistent artifact counts', () => {
for (const report of [{}, { ...passing, total: 99 }, { ...passing, passed: '1' }, { ...passing, tests: [] }]) {
assert.throws(() => resultSummary(report));
}
});
const version = {
ClientVersion: '2000899', ServerVersion: '2000899', PatchVersion: '1.41.7.8',
SourceRevision: '10948930', VersionDate: 'Aug 28 2026', VersionTime: '13:05:38',
};
test('reports the CS2 version from steam.inf fields', () => {
assert.match(versionSummary(version), /1\.41\.7\.8/);
assert.match(versionSummary(version), /server 2000899/);
assert.match(versionSummary(version), /10948930 — Aug 28 2026 13:05:38/);
});
test('never renders arbitrary server-controlled Markdown', () => {
assert.throws(() => versionSummary({ ...version, PatchVersion: '[click](https://example.org)' }));
assert.throws(() => versionSummary({ ...version, VersionDate: '@everyone' }));
});

for (const scenario of ['success', 'manual-dispatch', 'new-head', 'build-failure', 'timeout', 'cancelled']) {
test(`publishes check and PR comment: ${scenario}`, async () => {
const originalCwd = process.cwd();
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-report-'));
const values = {
TESTED_SHA: sha, CHECK_ID: '1', COMMENT_ID: '2', NATIVE_RESULT: 'success',
MANAGED_RESULT: scenario === 'build-failure' ? 'failure' : 'success',
SMOKE_RESULT: scenario === 'cancelled' ? 'cancelled' : scenario === 'timeout' ? 'failure' : 'success',
};
const originalEnv = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
try {
process.chdir(directory);
Object.assign(process.env, values);
fs.mkdirSync('smoke-results');
if (!['build-failure', 'timeout', 'cancelled'].includes(scenario)) {
fs.writeFileSync('smoke-results/smoke-results.json', JSON.stringify(passing));
fs.writeFileSync('smoke-results/server-version.json', JSON.stringify(version));
}
const input = request(scenario === 'manual-dispatch' ? { eventName: 'workflow_dispatch', prNumber: '123' } : {});
const published = {};
input.github.rest.pulls.get = async ({ pull_number }) => {
assert.equal(pull_number, scenario === 'manual-dispatch' ? 123 : 42);
return { data: { head: { sha: scenario === 'new-head' ? 'b'.repeat(40) : sha } } };
};
input.github.rest.checks.update = async (args) => { published.check = args; };
input.github.rest.issues.updateComment = async (args) => { published.comment = args; };
input.core.summary = { addRaw(text) { published.summary = text; return this; }, async write() {} };
await report(input);
assert.equal(published.check.status, 'completed');
assert.equal(published.check.conclusion, ['success', 'manual-dispatch', 'new-head'].includes(scenario) ? 'success' : scenario === 'cancelled' ? 'cancelled' : 'failure');
assert.match(published.comment.body, new RegExp(sha));
assert.match(published.comment.body, /actions\/runs\/123/);
if (scenario === 'new-head') assert.match(published.comment.body, /does \*\*not\*\* cover the latest head/);
if (scenario === 'success') assert.match(published.comment.body, /CS2:\*\* 1\.41\.7\.8/);
if (scenario === 'timeout') assert.match(published.comment.body, /No valid, complete native test report/);
} finally {
process.chdir(originalCwd);
fs.rmSync(directory, { recursive: true, force: true });
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
}
Loading
Loading