Skip to content
Merged
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
74 changes: 74 additions & 0 deletions .github/scripts/check-csharp-pipeline-status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node

const allJobNames = [
"test",
"releasePreflight",
"pushNuGetPackageToGitHubPackageRegistry",
"pushToNuget",
"publishRelease",
"findChangedCsFiles",
"generatePdfWithCode",
"buildDocumentation",
"publishDocumentation",
];

export const evaluatePipeline = ({ eventName, documentationChanged, needs }) => {
const required = new Set(["test", "findChangedCsFiles"]);

if (documentationChanged) {
required.add("generatePdfWithCode");
required.add("buildDocumentation");
}

if (eventName === "push") {
required.add("releasePreflight");
required.add("pushNuGetPackageToGitHubPackageRegistry");
required.add("pushToNuget");
required.add("publishRelease");
if (documentationChanged) {
required.add("publishDocumentation");
}
}

const failures = [];
for (const name of allJobNames) {
const result = needs[name]?.result;
if (!result) {
failures.push(`${name}: result is missing`);
} else if (required.has(name) && result !== "success") {
failures.push(`${name}: required job finished with ${result}`);
} else if (!required.has(name) && !["success", "skipped"].includes(result)) {
failures.push(`${name}: optional job finished with ${result}`);
}
}

return { passed: failures.length === 0, failures };
};

export const run = (environment = process.env) => {
let needs;
try {
needs = JSON.parse(environment.NEEDS_JSON ?? "");
} catch (error) {
console.error(`::error title=C# pipeline status invalid::${error.message}`);
return 1;
}

const result = evaluatePipeline({
eventName: environment.EVENT_NAME ?? "",
documentationChanged: environment.DOCUMENTATION_CHANGED === "true",
needs,
});

for (const failure of result.failures) {
console.error(`::error title=C# pipeline failed::${failure}`);
}
if (result.passed) {
console.log("Every required C# pipeline job succeeded.");
}
return result.passed ? 0 : 1;
};

if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
process.exitCode = run();
}
85 changes: 85 additions & 0 deletions .github/scripts/check-csharp-pipeline-status.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node

import assert from "node:assert/strict";
import test from "node:test";
import { evaluatePipeline } from "./check-csharp-pipeline-status.mjs";

const results = (overrides = {}) =>
Object.fromEntries(
[
"test",
"releasePreflight",
"pushNuGetPackageToGitHubPackageRegistry",
"pushToNuget",
"publishRelease",
"findChangedCsFiles",
"generatePdfWithCode",
"buildDocumentation",
"publishDocumentation",
].map((name) => [name, { result: overrides[name] ?? "skipped" }]),
);

test("accepts a pull request after validation and documentation builds pass", () => {
const needs = results({
test: "success",
findChangedCsFiles: "success",
generatePdfWithCode: "success",
buildDocumentation: "success",
});
assert.deepEqual(
evaluatePipeline({ eventName: "pull_request", documentationChanged: true, needs }),
{ passed: true, failures: [] },
);
});

test("accepts expected documentation skips when no maintained input changed", () => {
const needs = results({
test: "success",
releasePreflight: "success",
pushNuGetPackageToGitHubPackageRegistry: "success",
pushToNuget: "success",
publishRelease: "success",
findChangedCsFiles: "success",
});
assert.deepEqual(
evaluatePipeline({ eventName: "push", documentationChanged: false, needs }),
{ passed: true, failures: [] },
);
});

test("turns a failed publish and its skipped release into an explicit failure", () => {
const needs = results({
test: "success",
releasePreflight: "success",
pushNuGetPackageToGitHubPackageRegistry: "success",
pushToNuget: "failure",
publishRelease: "skipped",
findChangedCsFiles: "success",
});
const result = evaluatePipeline({
eventName: "push",
documentationChanged: false,
needs,
});

assert.equal(result.passed, false);
assert.deepEqual(result.failures, [
"pushToNuget: required job finished with failure",
"publishRelease: required job finished with skipped",
]);
});

test("reports cancelled jobs instead of allowing a grey timeout", () => {
const needs = results({
test: "cancelled",
findChangedCsFiles: "skipped",
});
const result = evaluatePipeline({
eventName: "pull_request",
documentationChanged: false,
needs,
});

assert.equal(result.passed, false);
assert.match(result.failures.join("\n"), /test: required job finished with cancelled/);
});
127 changes: 127 additions & 0 deletions .github/scripts/csharp-workflow-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env node

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const workflowPath = new URL("../workflows/csharp.yml", import.meta.url);
const workflow = readFileSync(workflowPath, "utf8");

const getJobs = (source) => {
const jobs = new Map();
const lines = source.slice(source.indexOf("\njobs:\n") + 1).split("\n");
let currentName;
let currentLines = [];

for (const line of lines.slice(1)) {
const match = /^ ([A-Za-z][A-Za-z0-9]*):\s*$/.exec(line);
if (match) {
if (currentName) {
jobs.set(currentName, currentLines.join("\n"));
}
currentName = match[1];
currentLines = [line];
} else if (currentName) {
currentLines.push(line);
}
}
if (currentName) {
jobs.set(currentName, currentLines.join("\n"));
}
return jobs;
};

const jobs = getJobs(workflow);

test("configures Git's initial branch before checkout runs", () => {
const topLevel = workflow.slice(0, workflow.indexOf("\ndefaults:"));
assert.match(topLevel, /\n GIT_CONFIG_COUNT: '1'/);
assert.match(topLevel, /\n GIT_CONFIG_KEY_0: init\.defaultBranch/);
assert.match(topLevel, /\n GIT_CONFIG_VALUE_0: main/);
});

test("validates every maintained C# workflow input on pull requests", () => {
assert.match(workflow, /pull_request:/);
assert.match(workflow, /- '\.github\/scripts\/\*csharp\*'/);
assert.match(workflow, /- 'csharp\/\*\*'/);
});

test("uses repository-owned build scripts instead of mutable downloads", () => {
assert.doesNotMatch(workflow, /raw\.githubusercontent\.com/);
assert.doesNotMatch(workflow, /\bwget\b/);
assert.doesNotMatch(workflow, /python-pygments/);
assert.doesNotMatch(workflow, /apt-get install(?:\s+-y)?\s+nuget/);
});

test("declares least-privilege checkout credentials", () => {
const lines = workflow.split("\n");
const failures = [];

for (let index = 0; index < lines.length; index += 1) {
if (!lines[index].includes("uses: actions/checkout@")) {
continue;
}
const step = lines.slice(index, index + 8).join("\n");
if (!/persist-credentials:\s*false/.test(step)) {
failures.push(`line ${index + 1}: ${lines[index].trim()}`);
}
}

assert.deepEqual(failures, []);
});

test("caps every job so hangs cannot run indefinitely", () => {
assert.ok(jobs.size > 0, "workflow jobs should be parsed");
for (const [name, job] of jobs) {
assert.match(job, /\n timeout-minutes:\s*\d+/, `${name} has no timeout`);
}
});

test("pins the runner image so latest-image migrations do not create warnings", () => {
assert.doesNotMatch(workflow, /runs-on: ubuntu-latest/);
for (const [name, job] of jobs) {
assert.match(job, /\n runs-on: ubuntu-24\.04/, `${name} has an unpinned runner`);
}
});

test("gates both package publishers on a release preflight", () => {
const preflight = jobs.get("releasePreflight");
assert.ok(preflight, "releasePreflight job should exist");
assert.match(preflight, /NUGET_TOKEN: \$\{\{ secrets\.NUGET_TOKEN \}\}/);
assert.match(preflight, /GITHUB_TOKEN: \$\{\{ secrets\.GITHUB_TOKEN \}\}/);
assert.match(preflight, /preflight-csharp-release\.mjs/);

for (const name of [
"pushNuGetPackageToGitHubPackageRegistry",
"pushToNuget",
]) {
assert.match(
jobs.get(name),
/needs: \[test, releasePreflight\]/,
`${name} must wait for validation and preflight`,
);
}
});

test("builds documentation on pull requests and transfers the PDF", () => {
assert.ok(jobs.get("buildDocumentation"), "buildDocumentation job should exist");
assert.doesNotMatch(jobs.get("generatePdfWithCode"), /github\.event_name == 'push'/);
assert.doesNotMatch(jobs.get("buildDocumentation"), /github\.event_name == 'push'/);
assert.match(jobs.get("generatePdfWithCode"), /actions\/upload-artifact@/);
assert.doesNotMatch(workflow, /actions\/download-artifact@/);
assert.match(jobs.get("buildDocumentation"), /gh run download/);
assert.match(jobs.get("buildDocumentation"), /actions\/upload-artifact@/);
assert.match(jobs.get("publishDocumentation"), /gh run download/);
assert.match(jobs.get("publishDocumentation"), /github\.event_name == 'push'/);
});

test("aggregates every job result so skipped dependents cannot hide failures", () => {
const gate = jobs.get("pipelineStatus");
assert.ok(gate, "pipelineStatus job should exist");
assert.match(gate, /if: \$\{\{ always\(\) \}\}/);
assert.match(gate, /check-csharp-pipeline-status\.mjs/);

for (const name of [...jobs.keys()].filter((name) => name !== "pipelineStatus")) {
assert.match(gate, new RegExp(`\\n - ${name}(?:\\n|$)`), `${name} is not observed`);
}
});
81 changes: 81 additions & 0 deletions .github/scripts/generate-csharp-pdf.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash

set -euo pipefail

repository_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
output_directory=${1:-"$repository_root/csharp/_site"}
work_directory=$(mktemp -d)

cleanup() {
rm -rf "$work_directory"
}
trap cleanup EXIT

sudo apt-get update &
update_pid=$!
wait "$update_pid"
sudo apt-get install -y \
ghostscript \
python3-pygments \
texlive \
texlive-lang-cyrillic \
texlive-latex-extra &
install_pid=$!
wait "$install_pid"

document="$work_directory/document.tex"
cat > "$document" <<'LATEX'
\documentclass[11pt,a4paper,fleqn]{report}
\usepackage[left=5mm,top=5mm,right=5mm,bottom=5mm]{geometry}
\usepackage[T1]{fontenc}
\usepackage[T2A]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{fvextra}
\usepackage{minted}
\usemintedstyle{vs}
\usepackage{makeidx}
\usepackage[columns=1]{idxlayout}
\usepackage[tiny]{titlesec}
\makeindex
\titlespacing\chapter{0mm}{0mm}{0mm}
\titlespacing\section{0mm}{0mm}{0mm}
\DeclareUnicodeCharacter{221E}{\ensuremath{\infty}}
\DeclareUnicodeCharacter{FFFD}{\ensuremath{ }}
\begin{document}
\sffamily
\chapter*{LinksPlatform's Platform.Interfaces Class Library}
LATEX

while IFS= read -r -d '' source_file; do
relative_path=${source_file#"$repository_root/"}
latex_path=${relative_path//_/\\_}
{
printf '\\index{%s}\n' "$latex_path"
printf '\\section{%s}\n' "$latex_path"
printf '%s\n' '\begin{minted}[tabsize=2,breaklines,breakanywhere,linenos=true,xleftmargin=7mm,framesep=4mm]{csharp}'
sed $'1s/^\xEF\xBB\xBF//' "$source_file"
printf '%s\n' '\end{minted}'
} >> "$document"
done < <(
find \
"$repository_root/csharp/Platform.Interfaces" \
"$repository_root/csharp/Platform.Interfaces.Tests" \
-type f -name '*.cs' ! -path '*/bin/*' ! -path '*/obj/*' -print0 |
sort -z
)

cat >> "$document" <<'LATEX'
\printindex
\end{document}
LATEX

(
cd "$work_directory"
pdflatex -shell-escape -interaction=nonstopmode -halt-on-error document.tex
makeindex document.idx
pdflatex -shell-escape -interaction=nonstopmode -halt-on-error document.tex
)

mkdir -p "$output_directory"
cp "$work_directory/document.pdf" "$output_directory/Platform.Interfaces.pdf"
echo "Generated $output_directory/Platform.Interfaces.pdf"
Loading
Loading