diff --git a/.github/scripts/check-csharp-pipeline-status.mjs b/.github/scripts/check-csharp-pipeline-status.mjs new file mode 100755 index 00000000..ba59b8b2 --- /dev/null +++ b/.github/scripts/check-csharp-pipeline-status.mjs @@ -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(); +} diff --git a/.github/scripts/check-csharp-pipeline-status.test.mjs b/.github/scripts/check-csharp-pipeline-status.test.mjs new file mode 100755 index 00000000..a7008e3c --- /dev/null +++ b/.github/scripts/check-csharp-pipeline-status.test.mjs @@ -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/); +}); diff --git a/.github/scripts/csharp-workflow-policy.test.mjs b/.github/scripts/csharp-workflow-policy.test.mjs new file mode 100755 index 00000000..ff0d508c --- /dev/null +++ b/.github/scripts/csharp-workflow-policy.test.mjs @@ -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`); + } +}); diff --git a/.github/scripts/generate-csharp-pdf.sh b/.github/scripts/generate-csharp-pdf.sh new file mode 100755 index 00000000..11a43de7 --- /dev/null +++ b/.github/scripts/generate-csharp-pdf.sh @@ -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" diff --git a/.github/scripts/preflight-csharp-release.mjs b/.github/scripts/preflight-csharp-release.mjs new file mode 100755 index 00000000..5237f7b7 --- /dev/null +++ b/.github/scripts/preflight-csharp-release.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +/** + * Check the release credentials that can be verified without publishing. + * NuGet.org has no read-only API-key validation endpoint, so this can prove + * only that NUGET_TOKEN is present. An expired or incorrectly scoped key is + * still reported by `dotnet nuget push`, and release creation remains gated + * on that command succeeding. + */ + +export const evaluateCredentials = ({ githubToken, nugetToken }) => { + const failures = []; + + if (!githubToken) { + failures.push("GITHUB_TOKEN is unavailable"); + } + if (!nugetToken) { + failures.push("NUGET_TOKEN is not configured"); + } + + return { passed: failures.length === 0, failures }; +}; + +export const run = (environment = process.env) => { + const result = evaluateCredentials({ + githubToken: environment.GITHUB_TOKEN ?? "", + nugetToken: environment.NUGET_TOKEN ?? "", + }); + + for (const failure of result.failures) { + console.error(`::error title=C# release preflight failed::${failure}`); + } + + if (!result.passed) { + return 1; + } + + console.log("C# release credentials are configured."); + console.log( + "NuGet token presence is verified; NuGet.org exposes no read-only endpoint for validating expiry or package scope.", + ); + return 0; +}; + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + process.exitCode = run(); +} diff --git a/.github/scripts/preflight-csharp-release.test.mjs b/.github/scripts/preflight-csharp-release.test.mjs new file mode 100755 index 00000000..75df2ccb --- /dev/null +++ b/.github/scripts/preflight-csharp-release.test.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluateCredentials } from "./preflight-csharp-release.mjs"; + +test("fails when either release credential is absent", () => { + assert.deepEqual(evaluateCredentials({ githubToken: "", nugetToken: "" }), { + passed: false, + failures: ["GITHUB_TOKEN is unavailable", "NUGET_TOKEN is not configured"], + }); +}); + +test("passes when both release credentials are configured", () => { + assert.deepEqual( + evaluateCredentials({ githubToken: "github", nugetToken: "nuget" }), + { passed: true, failures: [] }, + ); +}); diff --git a/.github/scripts/publish-csharp-docs.sh b/.github/scripts/publish-csharp-docs.sh new file mode 100755 index 00000000..a7a6561e --- /dev/null +++ b/.github/scripts/publish-csharp-docs.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z ${GITHUB_REPOSITORY:-} || -z ${GITHUB_TOKEN:-} ]]; then + echo "GITHUB_REPOSITORY and GITHUB_TOKEN are required." >&2 + exit 1 +fi + +site_directory=${1:-_site} +if [[ ! -f "$site_directory/index.html" || ! -f "$site_directory/Platform.Interfaces.pdf" ]]; then + echo "$site_directory must contain index.html and Platform.Interfaces.pdf." >&2 + exit 1 +fi + +site_directory=$(cd "$site_directory" && pwd) +deploy_directory=$(mktemp -d) + +cleanup() { + rm -rf "$deploy_directory" +} +trap cleanup EXIT + +authorization=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0) +repository_url="https://github.com/${GITHUB_REPOSITORY}.git" +git -c http.extraheader="AUTHORIZATION: basic $authorization" clone \ + --branch gh-pages --depth 1 "$repository_url" "$deploy_directory" + +destination="$deploy_directory/csharp" +mkdir -p "$destination" +find "$destination" -mindepth 1 -delete +cp -a "$site_directory/." "$destination/" + +git -C "$deploy_directory" config user.name linksplatform +git -C "$deploy_directory" config user.email linksplatformtechnologies@gmail.com +git -C "$deploy_directory" add --all + +if git -C "$deploy_directory" diff --cached --quiet; then + echo "Documentation is already current." + exit 0 +fi + +git -C "$deploy_directory" commit -m "Deploy C# documentation: ${GITHUB_SHA:-unknown}" +git -C "$deploy_directory" -c http.extraheader="AUTHORIZATION: basic $authorization" \ + push origin HEAD:gh-pages diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 29f45feb..1eb11ce4 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -6,21 +6,24 @@ on: paths: - 'csharp/**' - 'README.md' - - '.github/scripts/validate-csharp-package.sh' + - '.github/scripts/*csharp*' - '.github/workflows/csharp.yml' pull_request: branches: main paths: - 'csharp/**' - 'README.md' - - '.github/scripts/validate-csharp-package.sh' + - '.github/scripts/*csharp*' - '.github/workflows/csharp.yml' permissions: + actions: read contents: read env: - SCRIPTS_BASE_URL: https://raw.githubusercontent.com/linksplatform/Scripts/main/MultiProjectRepository + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main defaults: run: @@ -28,174 +31,268 @@ defaults: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + - name: Setup .NET uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - - uses: actions/checkout@v7 - with: - submodules: true - name: Validate tests and NuGet package run: ../.github/scripts/validate-csharp-package.sh + - name: Test C# workflow safeguards + run: node --test ../.github/scripts/*csharp*.test.mjs + + releasePreflight: + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Check release credentials + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} + run: node ../.github/scripts/preflight-csharp-release.mjs + pushNuGetPackageToGitHubPackageRegistry: - needs: test + needs: [test, releasePreflight] if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read packages: write steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + - name: Setup .NET uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - - uses: actions/checkout@v7 - with: - submodules: true - name: Publish NuGet package to GitHub Package Registry env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | package_output="$RUNNER_TEMP/github-packages" - dotnet pack Platform.Interfaces/Platform.Interfaces.csproj -c Release --output "$package_output" -warnaserror + dotnet pack Platform.Interfaces/Platform.Interfaces.csproj --configuration Release --output "$package_output" --nologo -warnaserror dotnet nuget add source https://nuget.pkg.github.com/linksplatform/index.json --name GitHub --username linksplatform --password "$GITHUB_TOKEN" --store-password-in-clear-text dotnet nuget push "$package_output"/*.nupkg --source GitHub --skip-duplicate pushToNuget: - needs: test + needs: [test, releasePreflight] if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + - name: Setup .NET uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - - uses: actions/checkout@v7 - with: - submodules: true - name: Publish NuGet package env: - NUGETTOKEN: ${{ secrets.NUGET_TOKEN }} + NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} run: | package_output="$RUNNER_TEMP/nuget-packages" - dotnet pack Platform.Interfaces/Platform.Interfaces.csproj -c Release --output "$package_output" -warnaserror - dotnet nuget push "$package_output"/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$NUGETTOKEN" --skip-duplicate + dotnet pack Platform.Interfaces/Platform.Interfaces.csproj --configuration Release --output "$package_output" --nologo -warnaserror + dotnet nuget push "$package_output"/*.nupkg --source https://api.nuget.org/v3/index.json --api-key "$NUGET_TOKEN" --skip-duplicate publishRelease: needs: [pushNuGetPackageToGitHubPackageRegistry, pushToNuget] if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + - name: Setup .NET uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - - uses: actions/checkout@v7 - with: - submodules: true - - name: Read project information - run: | - export REPOSITORY_NAME=$(basename ${{ github.repository }}) - wget "$SCRIPTS_BASE_URL/read_csharp_package_info.sh" - bash ./read_csharp_package_info.sh - - name: Publish release + - name: Publish GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - export REPOSITORY_NAME=$(basename ${{ github.repository }}) - export CSHARP_PACKAGE_VERSION="$(/dev/null 2>&1; then + echo "Release ${tag} already exists." + else + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$title" --notes "$notes" + fi findChangedCsFiles: needs: test - if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: - isCsFilesChanged: ${{ steps.setIsCsFilesChangedOutput.outputs.isCsFilesChanged }} + documentationChanged: ${{ steps.detect.outputs.documentationChanged }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Get changed files using defaults + persist-credentials: false + + - name: Get changed files id: changed-files uses: tj-actions/changed-files@v47 - - name: Set output isCsFilesChanged - id: setIsCsFilesChangedOutput + + - name: Detect documentation inputs + id: detect + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | - isCsFilesChanged='false' - echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}" - for changedFile in ${{ steps.changed-files.outputs.all_changed_files }}; do - if [[ $changedFile == *.cs ]] - then - echo "isCsFilesChanged='true'" - isCsFilesChanged='true' - fi + documentation_changed=false + for changed_file in $CHANGED_FILES; do + case "$changed_file" in + *.cs|README.md|.github/workflows/csharp.yml|.github/scripts/*csharp*|csharp/docfx.json|csharp/filter.yml|csharp/toc.yml) + documentation_changed=true + ;; + esac done - echo "isCsFilesChanged=${isCsFilesChanged}" >> "$GITHUB_OUTPUT" - echo "isCsFilesChanged: ${isCsFilesChanged}" + echo "documentationChanged=${documentation_changed}" >> "$GITHUB_OUTPUT" + echo "Documentation inputs changed: ${documentation_changed}" generatePdfWithCode: needs: [findChangedCsFiles] - if: ${{ github.event_name == 'push' && needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' }} - runs-on: ubuntu-latest + if: ${{ needs.findChangedCsFiles.outputs.documentationChanged == 'true' }} + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - name: Setup .NET - uses: actions/setup-dotnet@v6 + - uses: actions/checkout@v7 with: - dotnet-version: '8.0.x' + persist-credentials: false + submodules: true + + - name: Generate PDF with code + run: ../.github/scripts/generate-csharp-pdf.sh + + - name: Upload generated PDF + timeout-minutes: 5 + uses: actions/upload-artifact@v7 + with: + name: csharp-pdf + path: csharp/_site/Platform.Interfaces.pdf + if-no-files-found: error + retention-days: 1 + buildDocumentation: + needs: [findChangedCsFiles, generatePdfWithCode] + if: ${{ needs.findChangedCsFiles.outputs.documentationChanged == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: - uses: actions/checkout@v7 with: + persist-credentials: false submodules: true - - name: Generate PDF with code + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Build API documentation + run: | + dotnet tool install docfx --tool-path "$RUNNER_TEMP/docfx" --version 2.80.1 + "$RUNNER_TEMP/docfx/docfx" docfx.json --warningsAsErrors + cp _site/README.html _site/index.html + + - name: Download generated PDF + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name csharp-pdf --dir _site + + - name: Validate documentation output run: | - export REPOSITORY_NAME=$(basename ${{ github.repository }}) - wget "$SCRIPTS_BASE_URL/format-csharp-files.py" - wget "$SCRIPTS_BASE_URL/format-csharp-document.sh" - wget "$SCRIPTS_BASE_URL/generate-csharp-pdf.sh" - bash ./generate-csharp-pdf.sh + test -s _site/index.html + test -s _site/Platform.Interfaces.pdf + + - name: Upload documentation site + timeout-minutes: 5 + uses: actions/upload-artifact@v7 + with: + name: csharp-documentation + path: csharp/_site + if-no-files-found: error + retention-days: 1 publishDocumentation: - needs: [findChangedCsFiles] - if: ${{ github.event_name == 'push' && needs.findChangedCsFiles.outputs.isCsFilesChanged == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 30 + needs: [findChangedCsFiles, buildDocumentation] + if: ${{ github.event_name == 'push' && needs.findChangedCsFiles.outputs.documentationChanged == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: + actions: read contents: write steps: - - name: Setup .NET - uses: actions/setup-dotnet@v6 + - uses: actions/checkout@v7 with: - dotnet-version: '8.0.x' + persist-credentials: false + - name: Download documentation site + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name csharp-documentation --dir _site + + - name: Publish documentation to gh-pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ../.github/scripts/publish-csharp-docs.sh _site + + pipelineStatus: + needs: + - test + - releasePreflight + - pushNuGetPackageToGitHubPackageRegistry + - pushToNuget + - publishRelease + - findChangedCsFiles + - generatePdfWithCode + - buildDocumentation + - publishDocumentation + if: ${{ always() }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: - uses: actions/checkout@v7 with: - submodules: true - - name: Publish documentation to gh-pages branch + persist-credentials: false + + - name: Check aggregate pipeline status env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - export REPOSITORY_NAME=$(basename ${{ github.repository }}) - wget "$SCRIPTS_BASE_URL/docfx.json" - wget "$SCRIPTS_BASE_URL/filter.yml" - wget "$SCRIPTS_BASE_URL/toc.yml" - wget "$SCRIPTS_BASE_URL/publish-csharp-docs.sh" - bash ./publish-csharp-docs.sh + DOCUMENTATION_CHANGED: ${{ needs.findChangedCsFiles.outputs.documentationChanged }} + EVENT_NAME: ${{ github.event_name }} + NEEDS_JSON: ${{ toJSON(needs) }} + run: node ../.github/scripts/check-csharp-pipeline-status.mjs diff --git a/csharp/docfx.json b/csharp/docfx.json new file mode 100644 index 00000000..f016de80 --- /dev/null +++ b/csharp/docfx.json @@ -0,0 +1,39 @@ +{ + "metadata": [ + { + "src": [ + { + "files": ["Platform.Interfaces/Platform.Interfaces.csproj"] + } + ], + "dest": "obj/api", + "filter": "filter.yml" + } + ], + "build": { + "content": [ + { + "files": ["**/*.yml"], + "src": "obj/api", + "dest": "api" + }, + { + "files": ["README.md"], + "src": ".." + }, + { + "files": ["toc.yml"] + } + ], + "globalMetadata": { + "_appTitle": "LinksPlatform's Platform.Interfaces Library", + "_enableSearch": true, + "_gitContribute": { + "branch": "main" + }, + "_gitUrlPattern": "github" + }, + "markdownEngineName": "markdig", + "dest": "_site" + } +} diff --git a/csharp/filter.yml b/csharp/filter.yml new file mode 100644 index 00000000..02471625 --- /dev/null +++ b/csharp/filter.yml @@ -0,0 +1,5 @@ +apiRules: + - exclude: + uidRegex: (Tests|Benchmarks)(\.[A-Za-z]+)?$ + - exclude: + uidRegex: CSharpToCppTranslator$ diff --git a/csharp/toc.yml b/csharp/toc.yml new file mode 100644 index 00000000..89c0820a --- /dev/null +++ b/csharp/toc.yml @@ -0,0 +1,4 @@ +- name: Home + href: ../README.md +- name: API Documentation + href: obj/api/toc.yml