From f7f841b271b26cb5fe15ab9f70b7acb0b02df605 Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 11 Nov 2026 11:01:10 +0200 Subject: [PATCH 1/4] ci: run the examples and the README C# code against the staging API A new workflow, examples.yml, runs on ubuntu-latest and macos-latest for pull requests, pushes to main, every Monday and on demand: - builds the solution and runs all 21 examples against https://stag-v2.convertapi.com; - makes a console project of each of the 44 C# blocks in README.md, referencing the library source, and builds them all (fragments are spliced into the quick start program); - runs the quick start and the blocks under the "... in C#" task headings with sample files from Examples/TestFiles; - fails when a program fails, prints a caught API error, saves nothing, or prints the token or a Secret or ApiKey field. The programs stay as people copy them: MSBuild imports .github/ci/staging-host.targets only when CI sets CustomAfterMicrosoftCommonTargets, and it compiles in StagingHost.cs, a module initializer that sets ConvertApi.ApiBaseUri before Main. A failed run is retried twice, 30 s apart. Examples/TestFiles gains test.html and test.xlsx for the HTML and Excel README snippets. Co-Authored-By: Claude Opus 5.5 --- .github/ci/StagingHost.cs | 33 ++ .github/ci/check_csharp.py | 515 ++++++++++++++++++++++++++++++++ .github/ci/staging-host.targets | 13 + .github/workflows/examples.yml | 124 ++++++++ Examples/TestFiles/test.html | 23 ++ Examples/TestFiles/test.xlsx | Bin 0 -> 2414 bytes 6 files changed, 708 insertions(+) create mode 100644 .github/ci/StagingHost.cs create mode 100755 .github/ci/check_csharp.py create mode 100644 .github/ci/staging-host.targets create mode 100644 .github/workflows/examples.yml create mode 100644 Examples/TestFiles/test.html create mode 100644 Examples/TestFiles/test.xlsx diff --git a/.github/ci/StagingHost.cs b/.github/ci/StagingHost.cs new file mode 100644 index 0000000..de96e79 --- /dev/null +++ b/.github/ci/StagingHost.cs @@ -0,0 +1,33 @@ +// CI only. This file is never part of an example or of the code in README.md. +// +// The Examples workflow (.github/workflows/examples.yml) compiles it into each example and README program +// it builds, through staging-host.targets. The module initializer runs before Main, so ConvertApi.ApiBaseUri +// already points at the staging API when the program creates its client, and the code people copy +// stays exactly as it is in the repository. +namespace ConvertApiCi +{ + internal static class StagingHost + { + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void UseBaseUriFromEnvironment() + { + var baseUri = System.Environment.GetEnvironmentVariable("CONVERTAPI_BASE_URI"); + if (string.IsNullOrEmpty(baseUri)) + return; + + ConvertApiDotNet.ConvertApi.ApiBaseUri = baseUri; + + // The workflow fails a run that does not print this line, so a program built without this + // file cannot pass while it calls the production API. + System.Console.Error.WriteLine("[ci] ConvertApi.ApiBaseUri = " + baseUri); + + // A README program does not catch errors, and the crash message leaves out what the API said, + // such as which parameter it rejected. Print that too. + System.AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + if (e.ExceptionObject is ConvertApiDotNet.Exceptions.ConvertApiException error) + System.Console.Error.WriteLine($"[ci] The API answered {(int)error.StatusCode}: {error.Response}"); + }; + } + } +} diff --git a/.github/ci/check_csharp.py b/.github/ci/check_csharp.py new file mode 100755 index 0000000..4ff9738 --- /dev/null +++ b/.github/ci/check_csharp.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""Runs the C# examples and the C# code in README.md against the ConvertAPI staging API. + +CI only: the Examples workflow (.github/workflows/examples.yml) calls it. MSBuild imports +staging-host.targets into every program that job builds, so each program calls CONVERTAPI_BASE_URI +without a change to the code people copy. + + check_csharp.py examples [NAME ...] run every project in Examples/, or the named ones (built beforehand) + check_csharp.py readme-build --work DIR make a console project of each C# block in README.md and build it + check_csharp.py readme-run --work DIR run the quick start and the "... in C#" task blocks + +The runs need CONVERTAPI_TOKEN (a staging API token) and CONVERTAPI_BASE_URI. The staging API answers 503 +in bursts, so a failed run is tried again up to twice, CI_RETRY_DELAY seconds apart (30 by default). +Only the Python 3 standard library is used. To run it on your machine, from the repository root: + + export CONVERTAPI_TOKEN=your-staging-token CONVERTAPI_BASE_URI=https://stag-v2.convertapi.com + export CustomAfterMicrosoftCommonTargets="$PWD/.github/ci/staging-host.targets" + dotnet build ConvertApi.sln -c Release && python3 .github/ci/check_csharp.py examples +""" + +import argparse +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +EXAMPLES = REPO / "Examples" +SAMPLES = EXAMPLES / "TestFiles" +README = REPO / "README.md" +LIBRARY = REPO / "ConvertApi" / "ConvertApi.csproj" +CONFIGURATION = "Release" + +ATTEMPTS = 3 +RETRY_DELAY = int(os.environ.get("CI_RETRY_DELAY", "30")) +RUN_TIMEOUT = 300 # seconds for one run of one program + +# StagingHost.cs prints this line before Main. A run without it did not call the staging API. +STAGING_LINE = "[ci] ConvertApi.ApiBaseUri = " + +# The examples catch ConvertApiException, print it and still exit with 0, so these lines mark a failed call. +ERROR_LINE = re.compile( + r"^(Status Code:|Response:|Unhandled exception|Failed to |Call failed:|Reload failed:|Conversion failed)", + re.MULTILINE, +) + +# An example that shows error handling passes only when it prints the error it is written to provoke. +EXPECTED_OUTPUT = { + # Sends a Word file to the PDF split converter, which the API rejects with 400 Bad Request. + "ExceptionHandling": re.compile(r"^Status Code: BadRequest$", re.MULTILINE), +} + +# Account fields that must never reach the public log. The token itself is checked by value. +SECRET_FIELD = re.compile(r"(?i)\b(secret|api[ _-]?key)\s*[=:]") + +# README.md names its input files document.docx, page.html and so on; the runs copy these samples in under those names. +SAMPLE_FOR_EXTENSION = { + ".docx": "test.docx", + ".html": "test.html", + ".pdf": "test.pdf", + ".pptx": "test.pptx", + ".xlsx": "test.xlsx", +} + +# The README blocks that run against staging: the quick start program and the blocks under the +# "... in C#" task headings. Every other block is only built. +QUICK_START_HEADING = "Quick start" +RUN_HEADING = re.compile(r" in C#$") + +# A complete README program has this using directive. Any other C# block is a fragment. +PROGRAM_START = re.compile(r"^using ConvertApiDotNet;", re.MULTILINE) + +# The local file names a block uploads: new ConvertApiFileParam("document.pdf") or ("CompareFile", "revised.docx"). +FILE_PARAM = re.compile(r'new ConvertApiFileParam\(\s*(?:"\w+"\s*,\s*)?"([^"]+)"\s*\)') + + +# --------------------------------------------------------------------------------------------------------- +# Output for the GitHub Actions log + + +def escape_data(text): + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(text): + return escape_data(text).replace(":", "%3A").replace(",", "%2C") + + +def annotate(level, message, title=None, file=None, line=None): + """Prints an error, warning or notice annotation, which GitHub also shows on the run's summary page.""" + props = [] + if file: + props.append(f"file={escape_property(file)}") + if line: + props.append(f"line={line}") + if title: + props.append(f"title={escape_property(title)}") + print(f"::{level} {','.join(props)}::{escape_data(message)}" if props else f"::{level}::{escape_data(message)}") + + +def print_program_output(text, token): + """Prints a program's output with workflow commands switched off, so nothing it prints is run as one.""" + if token: + text = text.replace(token, "***") + lines = text.splitlines() + if len(lines) > 60: + lines = lines[:40] + [f"... {len(lines) - 60} more lines ..."] + lines[-20:] + marker = secrets.token_hex(16) + print(f"::stop-commands::{marker}") + for line in lines: + line = "".join(ch if ch.isprintable() or ch == "\t" else "?" for ch in line) + if SECRET_FIELD.search(line): + line = "(line hidden: it names a secret field)" + print(" " + (line if len(line) <= 300 else line[:300] + " ...")) + print(f"::{marker}::") + + +def write_summary(markdown): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a", encoding="utf-8") as f: + f.write(markdown + "\n") + + +def table_cell(text): + return str(text).replace("|", "\\|").replace("\n", " ") + + +# --------------------------------------------------------------------------------------------------------- +# Running a program + + +def require_staging(): + missing = [name for name in ("CONVERTAPI_TOKEN", "CONVERTAPI_BASE_URI") if not os.environ.get(name)] + if missing: + annotate("error", f"{' and '.join(missing)} not set, so nothing can run against the staging API") + sys.exit(1) + return os.environ["CONVERTAPI_TOKEN"] + + +def find_leaks(text, token): + leaks = [] + if token and token in text: + leaks.append("the output contains the API token") + match = SECRET_FIELD.search(text) + if match: + leaks.append(f"the output prints a secret field ({match.group(1)})") + return leaks + + +def run_with_retries(label, command, cwd, check, token, env=None, reset=None): + """Runs a program until check() finds no problem, at most ATTEMPTS times. Returns (passed, attempts, problems).""" + problems = [] + for attempt in range(1, ATTEMPTS + 1): + if reset: + reset() + started = time.monotonic() + try: + completed = subprocess.run( + command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=RUN_TIMEOUT, + ) + exit_code, raw = completed.returncode, completed.stdout + except subprocess.TimeoutExpired as e: + exit_code, raw = None, e.stdout or b"" + seconds = time.monotonic() - started + text = raw.decode("utf-8", "replace") + + leaks = find_leaks(text, token) + problems = leaks or check(exit_code, text) + if not problems: + print(f"::group::{label}: passed ({seconds:.1f} s{f', attempt {attempt}' if attempt > 1 else ''})") + print_program_output(text, token) + print("::endgroup::") + return True, attempt, [] + + print(f"{label}: FAILED on attempt {attempt} of {ATTEMPTS} ({seconds:.1f} s): {'; '.join(problems)}") + print_program_output(text, token) + # A leak is in the code, not in the staging API, so another attempt would only print it again. + if leaks or attempt == ATTEMPTS: + break + annotate("warning", f"{label} failed on attempt {attempt}: {'; '.join(problems)}. Trying again in {RETRY_DELAY} s.", + title="Retrying a failed run") + time.sleep(RETRY_DELAY) + return False, attempt, problems + + +def common_problems(exit_code, text): + problems = [] + if exit_code is None: + problems.append(f"timed out after {RUN_TIMEOUT} s") + elif exit_code != 0: + problems.append(f"exit code {exit_code}") + if STAGING_LINE not in text: + problems.append("it did not run against the staging API: StagingHost.cs was not compiled in") + return problems + + +# --------------------------------------------------------------------------------------------------------- +# Examples + + +def run_examples(args): + token = require_staging() + projects = sorted(p for p in EXAMPLES.glob("*/*.csproj") if not args.names or p.stem in args.names) + if not projects or (args.names and len(projects) != len(set(args.names))): + annotate("error", f"No example projects found in {EXAMPLES} for {' '.join(args.names) or 'any name'}") + return 1 + + results = [] + with tempfile.TemporaryDirectory(prefix="examples-") as scratch: + # The examples save their results in the temp folder; keep them in this run's scratch folder. + env = dict(os.environ, TMPDIR=scratch + os.sep) + for csproj in projects: + name = csproj.stem + expected = EXPECTED_OUTPUT.get(name) + + def check(exit_code, text, expected=expected): + problems = common_problems(exit_code, text) + if expected: + if not expected.search(text): + problems.append(f"it did not print the error it is written to show ({expected.pattern})") + else: + error = ERROR_LINE.search(text) + if error: + line = text[error.start():].splitlines()[0] + problems.append(f"it printed an error: {line[:200]}") + return problems + + # Each example runs from an empty folder: it finds its sample files next to the program itself. + cwd = tempfile.mkdtemp(prefix=f"{name}-", dir=scratch) + command = ["dotnet", "run", "--project", str(csproj), "-c", CONFIGURATION, "--no-build"] + passed, attempts, problems = run_with_retries(name, command, cwd, check, token, env=env) + results.append((name, passed, attempts, problems)) + + failed = [r for r in results if not r[1]] + for name, _, _, problems in failed: + annotate("error", f"{name}: {'; '.join(problems)}", title="Example failed", + file=f"Examples/{name}/Program.cs") + print(f"\nExamples: {len(results) - len(failed)} of {len(results)} passed against the staging API.") + + rows = [f"| {name} | {'passed' if passed else 'FAILED: ' + table_cell('; '.join(problems))} | {attempts} |" + for name, passed, attempts, problems in results] + write_summary("### Examples on the staging API\n\n| Example | Result | Attempts |\n|---|---|---|\n" + "\n".join(rows) + "\n") + return 1 if failed else 0 + + +# --------------------------------------------------------------------------------------------------------- +# README.md + + +def extract_blocks(markdown): + """Returns the C# code blocks of a Markdown file with their first line number and the heading above them.""" + blocks, heading, lines, i = [], "", markdown.splitlines(), 0 + while i < len(lines): + fence = re.match(r"^\s*(`{3,}|~{3,})\s*([^\s`]*)", lines[i]) + if fence: + marker, language = fence.group(1), fence.group(2).lower() + end = i + 1 + while end < len(lines) and not lines[end].strip().startswith(marker): + end += 1 + if language in ("csharp", "cs", "c#"): + blocks.append({ + "index": len(blocks) + 1, + "line": i + 2, # README.md line of the block's first line of code + "heading": heading, + "code": "\n".join(lines[i + 1:end]) + "\n", + }) + i = end + 1 + continue + # A closing run of # needs a space before it, so the # of "in C#" stays. + title = re.match(r"^#{1,6}\s+(.*?)(?:\s+#+)?\s*$", lines[i]) + if title: + heading = title.group(1) + i += 1 + return blocks + + +def code_lines(code): + return [line for line in code.splitlines() if line.strip() and not line.strip().startswith("//")] + + +def splice(fragment, quick_start): + """Puts a fragment into the quick start program. Returns (program, how) or (None, why it does not fit).""" + lines = quick_start.rstrip("\n").splitlines() + fragment_lines = fragment.rstrip("\n").splitlines() + first = code_lines(fragment)[0].strip() if code_lines(fragment) else "" + + declared = re.match(r"var (\w+)\s*=", first) + if declared: + name = declared.group(1) + for start, line in enumerate(lines): + if re.match(rf"\s*var {name}\s*=", line): + end = start + while end < len(lines) - 1 and not lines[end].rstrip().endswith(";"): + end += 1 + program = lines[:start] + fragment_lines + lines[end + 1:] + return "\n".join(program) + "\n", f"replaces the quick start's `var {name}` statement" + return None, f"declares `{name}`, which the quick start program does not declare" + + # Settings such as ConvertApi.ApiBaseUri go before the quick start's first statement. + if code_lines(fragment) and all(re.match(r"ConvertApi\.\w+\s*=.*;$", line.strip()) for line in code_lines(fragment)): + last_using = max(i for i, line in enumerate(lines) if line.startswith("using ")) + program = lines[:last_using + 1] + [""] + fragment_lines + lines[last_using + 1:] + return "\n".join(program) + "\n", "goes before the quick start's first statement" + + return None, "it is neither a statement of the quick start program nor a ConvertApi setting" + + +def slug(text): + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "block" + + +def run_quiet(command, cwd): + completed = subprocess.run(command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if completed.returncode != 0: + print(completed.stdout) + raise SystemExit(f"{' '.join(command)} failed with exit code {completed.returncode}") + return completed.stdout + + +def readme_build(args): + work = Path(args.work).resolve() + if work.exists(): + shutil.rmtree(work) + (work / "projects").mkdir(parents=True) + # The same SDK as the repository, although the projects live outside it. + shutil.copy(REPO / "global.json", work / "global.json") + + blocks = extract_blocks(README.read_text(encoding="utf-8")) + if not blocks: + annotate("error", "README.md has no csharp code blocks", file="README.md") + return 1 + quick_start = next((b for b in blocks if b["heading"] == QUICK_START_HEADING and PROGRAM_START.search(b["code"])), None) + if quick_start is None: + annotate("error", f"README.md has no complete program under the heading '{QUICK_START_HEADING}'", file="README.md") + return 1 + + # Each project starts from what `dotnet new console` makes and references the library source in this + # repository, so README.md is checked against the code the next release ships. + run_quiet(["dotnet", "new", "console", "--name", "Template", "--output", str(work / "template"), "--no-restore"], cwd=work) + template = (work / "template" / "Template.csproj").read_text(encoding="utf-8") + end = template.rindex("") + csproj = (template[:end].rstrip() + "\n\n \n" + f' \n' + " \n\n\n") + + entries, skipped = [], [] + for block in blocks: + if PROGRAM_START.search(block["code"]): + program, kind, how = block["code"], "program", "" + else: + program, how = splice(block["code"], quick_start["code"]) + kind = "fragment" + if program is None: + skipped.append((block, how)) + continue + name = f"b{block['index']:02d}-{slug(block['heading'])}" + folder = work / "projects" / name + folder.mkdir() + (folder / f"{name}.csproj").write_text(csproj, encoding="utf-8") + (folder / "Program.cs").write_text(program, encoding="utf-8") + run = kind == "program" and (block is quick_start or bool(RUN_HEADING.search(block["heading"]))) + entries.append({ + "name": name, "line": block["line"], "heading": block["heading"], "kind": kind, "how": how, + "run": run, "inputs": FILE_PARAM.findall(program) if run else [], + "saves_files": bool(re.search(r"\.SaveFiles?Async\(", program)), + }) + + solution = "\n" + "".join( + f' \n' for e in entries) + "\n" + (work / "readme.slnx").write_text(solution, encoding="utf-8") + + print(f"README.md has {len(blocks)} C# blocks: {sum(e['kind'] == 'program' for e in entries)} complete programs, " + f"{sum(e['kind'] == 'fragment' for e in entries)} fragments spliced into the quick start program, " + f"{len(skipped)} fragments skipped.") + for e in entries: + if e["kind"] == "fragment": + print(f" README.md line {e['line']} ({e['heading']}): the fragment {e['how']}") + print("Skipped fragments:" + ("" if skipped else " none")) + for block, why in skipped: + print(f" README.md line {block['line']} ({block['heading']}): skipped, {why}") + annotate("notice", f"Not built: {why}", title="README fragment skipped", file="README.md", line=block["line"]) + + print(f"::group::dotnet build of {len(entries)} README projects") + build = subprocess.run(["dotnet", "build", "readme.slnx", "-c", CONFIGURATION, "-nologo", "-tl:off"], cwd=work, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + print(build.stdout) + print("::endgroup::") + + # file(line,column): error CS0103: message [project.csproj], printed once per error and again in the recap + error_line = re.compile(r"^\s*(?P[^\n]+?)\((?P\d+),(?P\d+)\): error (?P\w+): " + r"(?P.*?) \[(?P[^\]\n]+)\]$", re.MULTILINE) + errors = {} + for match in error_line.finditer(build.stdout): + project = Path(match.group("project").split("::")[0]).stem + errors.setdefault(project, set()).add((int(match.group("line")), match.group("code"), match.group("message"))) + + failed = 0 + for e in entries: + built = any((work / "projects" / e["name"] / "bin" / CONFIGURATION).glob(f"*/{e['name']}.dll")) + e["built"] = built + if built: + continue + failed += 1 + found = sorted(errors.get(e["name"], set())) + if not found: + annotate("error", "The program made from this block did not build; see the dotnet build output", + title="README block does not build", file="README.md", line=e["line"]) + for program_line, code, message in found: + # A complete program is the block verbatim, so its line numbers map onto README.md. + line = e["line"] + program_line - 1 if e["kind"] == "program" else e["line"] + annotate("error", f"{code}: {message}", title="README block does not build", file="README.md", line=line) + + (work / "blocks.json").write_text(json.dumps({"blocks": entries}, indent=2), encoding="utf-8") + + rows = [f"| {e['line']} | {table_cell(e['heading'])} | {e['kind']}{' (' + table_cell(e['how']) + ')' if e['how'] else ''} | " + f"{'built' if e['built'] else 'FAILED'} |" for e in entries] + rows += [f"| {b['line']} | {table_cell(b['heading'])} | fragment | skipped: {table_cell(why)} |" for b, why in skipped] + write_summary("### C# code in README.md\n\n| Line | Section | Block | Build |\n|---|---|---|---|\n" + "\n".join(rows) + "\n") + + print(f"\nREADME.md: {len(entries) - failed} of {len(entries)} projects built.") + if build.returncode != 0 and not failed: + annotate("error", "dotnet build failed, although every README project produced its program") + return 1 + return 1 if failed else 0 + + +def project_files(folder): + return {p for p in folder.rglob("*") if p.is_file() and not ({"bin", "obj"} & set(p.relative_to(folder).parts))} + + +def readme_run(args): + token = require_staging() + work = Path(args.work).resolve() + if not (work / "blocks.json").exists(): + annotate("error", f"{work / 'blocks.json'} is missing: run readme-build first") + return 1 + entries = [e for e in json.loads((work / "blocks.json").read_text(encoding="utf-8"))["blocks"] if e["run"]] + results = [] + for e in entries: + label = f"README.md line {e['line']} ({e['heading']})" + folder = work / "projects" / e["name"] + if not e["built"]: + results.append((e, False, 0, ["it did not build"])) + continue + + missing = [] + for name in sorted(set(e["inputs"])): + sample = SAMPLE_FOR_EXTENSION.get(Path(name).suffix.lower()) + if sample is None or not (SAMPLES / sample).exists(): + missing.append(name) + else: + shutil.copy(SAMPLES / sample, folder / name) + if missing: + results.append((e, False, 0, [f"no sample file in Examples/TestFiles for {', '.join(missing)}"])) + continue + + before = project_files(folder) + + def reset(folder=folder, before=before): + # A clean folder for each attempt, so only this attempt's results count. + for path in project_files(folder) - before: + path.unlink() + shutil.rmtree(folder / "output", ignore_errors=True) + + def check(exit_code, text, folder=folder, before=before, saves_files=e["saves_files"]): + problems = common_problems(exit_code, text) + if saves_files: + if not [p for p in project_files(folder) - before if p.stat().st_size > 0]: + problems.append("it saved no file") + elif not text.replace(STAGING_LINE, "").strip(): + problems.append("it printed nothing") + return problems + + command = ["dotnet", "run", "--no-build", "-c", CONFIGURATION] + passed, attempts, problems = run_with_retries(label, command, folder, check, token, reset=reset) + results.append((e, passed, attempts, problems)) + + failed = [r for r in results if not r[1]] + for e, _, _, problems in failed: + annotate("error", "; ".join(problems), title="README block failed on the staging API", file="README.md", line=e["line"]) + print(f"\nREADME.md: {len(results) - len(failed)} of {len(results)} programs passed against the staging API.") + + rows = [f"| {e['line']} | {table_cell(e['heading'])} | {'passed' if passed else 'FAILED: ' + table_cell('; '.join(problems))} | {attempts} |" + for e, passed, attempts, problems in results] + write_summary("### README.md programs on the staging API\n\n| Line | Section | Result | Attempts |\n|---|---|---|---|\n" + + "\n".join(rows) + "\n") + return 1 if failed or not results else 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + commands = parser.add_subparsers(dest="command", required=True) + examples = commands.add_parser("examples", help="run every project in Examples/") + examples.add_argument("names", nargs="*", help="run only these examples, by folder name") + examples.set_defaults(handler=run_examples) + for name, handler, text in (("readme-build", readme_build, "make and build a project of each C# block in README.md"), + ("readme-run", readme_run, "run the quick start and the task blocks")): + command = commands.add_parser(name, help=text) + command.add_argument("--work", required=True, help="folder for the generated projects, outside the repository") + command.set_defaults(handler=handler) + args = parser.parse_args() + sys.stdout.reconfigure(line_buffering=True) + return args.handler(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/ci/staging-host.targets b/.github/ci/staging-host.targets new file mode 100644 index 0000000..eb5d278 --- /dev/null +++ b/.github/ci/staging-host.targets @@ -0,0 +1,13 @@ + + + + + + diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000..63c3a7c --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,124 @@ +name: Examples +run-name: >- + ${{ github.event_name == 'pull_request' && format('Running the examples and README code of #{0} on staging', github.event.pull_request.number) + || format('Running the examples and README code of {0} on staging', github.ref_name) }} + +# The examples in Examples/ and the C# code in README.md are what people copy, so this workflow runs them +# against the staging API: a broken example or README snippet fails here instead of for a user. +# build.yml only compiles the examples and never builds the README code. +# +# The programs run exactly as people copy them. Every project this job builds imports +# .github/ci/staging-host.targets (through the CustomAfterMicrosoftCommonTargets variable below), which +# compiles .github/ci/StagingHost.cs into the programs: a module initializer that points +# ConvertApi.ApiBaseUri at CONVERTAPI_BASE_URI before Main runs. .github/ci/check_csharp.py then runs +# them, and the job fails when +# - an example exits with an error or prints an API error it caught (ExceptionHandling must print its 400); +# - a C# block in README.md does not build as its own console project against the library source; +# - the quick start or a "... in C#" task block in README.md fails on staging or saves no file; +# - any output contains the API token or a Secret or ApiKey field, because these logs are public; +# - a program did not print StagingHost's line, so it may have called the production API. +# A run that fails is tried again up to twice, 30 s apart, because the staging API answers 503 in bursts. +on: + pull_request: + branches: [main] + push: + branches: [main] + # The API can change without a commit here (a converter parameter goes away, a site starts blocking + # the renderer), so the examples also run every Monday. + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +# A newer push to the same pull request cancels the older run; other runs never cancel each other. +concurrency: + group: examples-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +env: + CONVERTAPI_BASE_URI: https://stag-v2.convertapi.com + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + +jobs: + examples: + name: examples (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + env: + # MSBuild reads environment variables as properties, so every project built in this job imports the + # staging switch. Nothing in the repository imports it. + CustomAfterMicrosoftCommonTargets: ${{ github.workspace }}/.github/ci/staging-host.targets + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Build the examples + id: build-examples + run: dotnet build ConvertApi.sln -c Release + + # Each C# block becomes Program.cs of a `dotnet new console` project that references the library + # source, so README.md is checked against what the next release ships. A fragment is spliced into the + # quick start program; one that does not fit is listed as skipped. Needs no token, so it also runs + # for pull requests from forks. + - name: Build the C# code in README.md + id: build-readme + if: ${{ !cancelled() }} + run: python3 .github/ci/check_csharp.py readme-build --work "$RUNNER_TEMP/readme" + + - name: Check the staging token + id: token + if: ${{ !cancelled() }} + env: + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + FROM_FORK: ${{ github.event.pull_request.head.repo.fork }} + run: | + set -euo pipefail + if [ -z "$CONVERTAPI_TOKEN" ]; then + if [ "$FROM_FORK" = true ]; then + echo "::error::A pull request from a fork gets no secrets, so its examples cannot run on staging. They run on main after the merge." + elif [ "$GITHUB_ACTOR" = "dependabot[bot]" ]; then + echo "::error::Dependabot runs get only Dependabot secrets, so CONVERTAPI_STAGING_TOKEN must also be a Dependabot secret." + else + echo "::error::The repository has no CONVERTAPI_STAGING_TOKEN secret, so the examples cannot run on staging." + fi + exit 1 + fi + status=$(curl -sS --retry 5 --retry-delay 10 -o "$RUNNER_TEMP/user.json" -w '%{http_code}' \ + -H "Authorization: Bearer $CONVERTAPI_TOKEN" "$CONVERTAPI_BASE_URI/user") + if [ "$status" != 200 ]; then + echo "::error::The staging API answered $status to CONVERTAPI_STAGING_TOKEN (GET /user)." + exit 1 + fi + # RetrieveUserInformation prints the account's name and email. This log is public, so mask both. + for field in FullName Email; do + value=$(jq -r --arg field "$field" '.[$field] // empty' "$RUNNER_TEMP/user.json") + if [ -n "$value" ]; then echo "::add-mask::$value"; fi + done + jq -r '"Staging account: \(.ConversionsConsumed) of \(.ConversionsTotal) conversions used"' "$RUNNER_TEMP/user.json" + rm "$RUNNER_TEMP/user.json" + + - name: Run the examples on staging + if: ${{ !cancelled() && steps.token.outcome == 'success' && steps.build-examples.outcome == 'success' }} + env: + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + run: python3 .github/ci/check_csharp.py examples + + # The quick start and the blocks under the "... in C#" task headings, with the sample files from + # Examples/TestFiles copied in under the names they use. The other blocks are only built. + - name: Run the README quick start and task code on staging + if: ${{ !cancelled() && steps.token.outcome == 'success' }} + env: + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + run: python3 .github/ci/check_csharp.py readme-run --work "$RUNNER_TEMP/readme" diff --git a/Examples/TestFiles/test.html b/Examples/TestFiles/test.html new file mode 100644 index 0000000..ce6af32 --- /dev/null +++ b/Examples/TestFiles/test.html @@ -0,0 +1,23 @@ + + + + +Sample order + + + +

Sample order

+

A small HTML file for the ConvertAPI .NET SDK examples. Convert it to PDF or to a Word document.

+ + + + + +
ItemQuantityPrice
Notebook24.50
Pen101.20
Folder52.80
+ + diff --git a/Examples/TestFiles/test.xlsx b/Examples/TestFiles/test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d9611c3a5293203839d813c5bd9fd917e5b44f1c GIT binary patch literal 2414 zcmZ`*2{=^y8$Py~FqSUiA}Y7==Z>ZHgk%{pn31(?DKq9uV;{@dGL*=YEK%06M6#4Y zg>acH7s(WnVl0!4(v<9zzkAgGxvu%&d(Lyt?>WEc`QGQe@ArN0_p`F#;S~n}U>Eq{ z&m!=78xmXqpw0sTV&IN5!q+Dd=M#9&F4PZ4I149wdsW#yv*gwgZDEU}?_ZHPE`@bC zMwS~gjv&?K;bhjc`bnRcGx?G=lq4i(+GT>M?k`Emqf z!d~AVkc7Vf8m`H_l%(duc(aKTnIn@U-`U)cg|{ThkG~K5+&|qRxLA7bk4F7frlQpj z86l2cF*TrkN}!a2;Ce0q=S5J1Z@rVB*9ga`i$W&G$}ae1#a!07+Kxdv2%RwSPB9|6 zzC0cK=uxpWw5xYxWut-OI&)2LATGL+bf7o7(7ptU=XH7Wbs11K_N>qCE+XyW85}Bo zGVu$xk$+=?+ag|wdtGWQ^(#3n7uVfgh|anGO%*HMJZX8)TAI;xoKD#EF<5e=mXjfRug1|v;mqU<6Lrl~f!DV@K)n*NQbAYFTf`@Z>qznov<-pMQqhO=q` z8OeamM8K8kr555FfXDdy;-9R*!or}@Zt2EoazxSw6g7Hm=@B?BO5ywi> zs=eYDE|J8x`&zd0EPUvb+VvU9lRUOUIYC^Ia-96E4VAgtJQ_n|bT_5);~y8dSFhdm zQKjAvRd+n)geZ7n=TzaHLStqtmP9ZeiY!7i5{JwNLg#ne%FcUON4%cpZbK=((}#U( z9GJ>~gEuxmZ0r}P+%$XwC4gR9C$lbgcYItoJ#H?%;IcQOpWmeChK%;A&`2pIu?OCk z4c$p~jCQSX1f;0N4FJ+VsQ$>~-wA)uX;o_5MXEZ?fW;OlW>1w|)-cI)?I90Wu>q`# z{{@VU*?eYefwC8#oU}LWr?i2W2q)hYW!8B&h)9}&?GRbLkr6i{L!`I9)sM#1(B5dA zJff~ZEp2vgow{(R&reFTg8Q1+zIcX5)oZ?7)icUi)jT-O{Z7f?2d}ZV<-2EsHtsUh zxaMQZgU5mS2%d}j9^XWCgT^NOa^Cv4E1fw#n_>(Vj<+FahKcj&AAI$Gz%Eu`y|HgD zQ&U#8FiPw{Wi#grPp+xmj?3xkK0bA)LwnQpz9ikJ71*iASDj-kA3z=CK|Q3lQ%|^n z!vzx5zAqYEH4v0av!w2mie*I#EYO{udipRVT*VjuMJ{9_9vclomiekx3mR&&R-Z?+ zWEX0wS^2bkxdoMz19n>baACTrnZC)5`6YjmZm1_j+qw8ygo+6C%kS`4k?YpwqyxVb zg|WMO4|lf6-^^^GBx=&u9$5B@nuc^<@;4VAHVx}6sV;bsnau!QTI^m?l@pQ zA0^TSv6`5eev*(qX|Mj4{>1i^R#Y>51-anIC=*RHLIyYdYJ`j)`J7{3F%}t^GvIx- zl&^6tTarHK6R>p3JA32^mL+hsxhy!x{9a^q=W7AK(W+IhodO}nQW_dSVfKT)v1O@X zZxAlH1>oFm0|PvLJP2Fj;GTak71AU-zkRkvXTC7QO*hV{FssIh4Y(F*mDXf9ntRe) zl(8_%^ro3k_t2xaIVuunD7nl;0hkTdyQntqrTzLU6^sw7vbShvy!zfnZvu+9bTW02D&2=P_*DK4uW-c)z_?ATK?*h5*b`XlV6^yx)G zi0;Qkp4cxciOmSw)k8d1QDkFT8NTQhir0uJjdM~M7vM<5IF!Qp}AlFgx2e0YLJiqeP z*>s{pqufIblRM^^rROyVLs$sJ8Df(`rn_fQqMayeSTXUEC{vWC8z5b*Bhqt-(SQlw z<&4KaV&=Dw>Mob#{QP9%Sm+LB+lyt7EJ*fP(jSIX^cR|JxAOet*Lc7L&VC0i(8!M-{&$Y`S*(i=HMTvG$#P(MEVg903pQ_+Jc_6Nl4!wsB;6|N4plGa^nD&i>p+ n(c}B)ZskPaEc Date: Wed, 11 Nov 2026 11:18:49 +0200 Subject: [PATCH 2/4] ci: run the examples on the production API with CONVERTAPI_CI_TOKEN Run the examples and the README code against the API people use, with a token kept for CI, instead of the staging API: - the programs call https://v2.convertapi.com, the SDK's default; the CI host (CiHost.cs, was StagingHost.cs) no longer changes the address; - CiHost.cs records every uploaded and result file and deletes it from the server when the program exits, instead of leaving it for 3 hours; - the token comes from the CONVERTAPI_CI_TOKEN secret, an Actions and a Dependabot secret; - the workflow runs for changes to ConvertApi/, Examples/, README.md, global.json or the CI files, every Monday and on demand; pull requests from forks are skipped; - the account details that RetrieveUserInformation prints stay out of the public log. Co-Authored-By: Claude Opus 5.5 --- .github/ci/CiHost.cs | 131 ++++++++++++++++++ .github/ci/StagingHost.cs | 33 ----- .github/ci/check_csharp.py | 73 ++++++---- .../{staging-host.targets => ci-host.targets} | 7 +- .github/workflows/examples.yml | 81 ++++++----- 5 files changed, 229 insertions(+), 96 deletions(-) create mode 100644 .github/ci/CiHost.cs delete mode 100644 .github/ci/StagingHost.cs rename .github/ci/{staging-host.targets => ci-host.targets} (58%) diff --git a/.github/ci/CiHost.cs b/.github/ci/CiHost.cs new file mode 100644 index 0000000..739d994 --- /dev/null +++ b/.github/ci/CiHost.cs @@ -0,0 +1,131 @@ +// CI only. This file is never part of an example or of the code in README.md. +// +// The Examples workflow (.github/workflows/examples.yml) compiles it into each example and README program it +// builds, through ci-host.targets. The programs call the API exactly as people run them. This file only: +// - prints the API address the program uses, a line the workflow requires, so it knows this file is in; +// - records the files the program uploads and the files the API returns, and deletes them from the server +// when the program exits, because the API would keep them for up to 3 hours; +// - prints what the API answered when the program crashes on a ConvertApiException, which the crash +// message leaves out. +// To run the programs against another API address, set CONVERTAPI_BASE_URI. The workflow does not set it. +namespace ConvertApiCi +{ + using System; + using System.Collections.Concurrent; + using System.Net.Http; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + + internal static class CiHost + { + private static int _deleted; + + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void Start() + { + var baseUri = Environment.GetEnvironmentVariable("CONVERTAPI_BASE_URI"); + if (!string.IsNullOrEmpty(baseUri)) + global::ConvertApiDotNet.ConvertApi.ApiBaseUri = baseUri; + Console.Error.WriteLine("[ci] ConvertApi.ApiBaseUri = " + global::ConvertApiDotNet.ConvertApi.ApiBaseUri); + + // Installs the recording client. Passing the current ApiToken (none yet) leaves the token to the program. + _ = new global::ConvertApiDotNet.ConvertApi(global::ConvertApiDotNet.ConvertApi.ApiToken, new RecordingClient()); + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + if (e.ExceptionObject is global::ConvertApiDotNet.Exceptions.ConvertApiException error) + Console.Error.WriteLine($"[ci] The API answered {(int)error.StatusCode}: {error.Response}"); + DeleteFiles(); + }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => DeleteFiles(); + } + + private static void DeleteFiles() + { + if (Interlocked.Exchange(ref _deleted, 1) == 1) + return; + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + int deleted = 0, total = 0; + foreach (var url in Recorder.FileUrls.Keys) + { + total++; + try + { + // As the SDK's DeleteFilesAsync does. 404: the program already deleted the file itself. + var response = http.DeleteAsync(url).GetAwaiter().GetResult(); + if (response.IsSuccessStatusCode || (int)response.StatusCode == 404) + deleted++; + else + Console.Error.WriteLine($"[ci] Deleting a file from the server returned {(int)response.StatusCode}"); + } + catch (Exception e) + { + Console.Error.WriteLine("[ci] Deleting a file from the server failed: " + e.Message); + } + } + Console.Error.WriteLine($"[ci] Deleted {deleted} of {total} files from the server"); + } + } + + // The SDK's default client is an HttpClient with ConvertApiConstants.HttpClientTimeOut, made on first use. + // This one is the same, with a handler that notes the URL of every file the API returns. + internal sealed class RecordingClient : global::ConvertApiDotNet.Interface.IConvertApiHttpClient + { + private readonly Lazy _client = new Lazy(() => + new HttpClient(new Recorder(new HttpClientHandler())) + { + Timeout = global::ConvertApiDotNet.Constants.ConvertApiConstants.HttpClientTimeOut + }); + + public HttpClient Client => _client.Value; + } + + internal sealed class Recorder : DelegatingHandler + { + internal static readonly ConcurrentDictionary FileUrls = new ConcurrentDictionary(); + + public Recorder(HttpMessageHandler inner) : base(inner) { } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = await base.SendAsync(request, cancellationToken); + var path = request.RequestUri?.AbsolutePath ?? ""; + // An upload answers with the uploaded file, a conversion with its result files: both carry a Url. + if (request.Method == HttpMethod.Post && response.IsSuccessStatusCode && + (path.StartsWith("/upload", StringComparison.Ordinal) || path.StartsWith("/convert/", StringComparison.Ordinal))) + { + await response.Content.LoadIntoBufferAsync(); + try + { + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + Collect(json.RootElement); + } + catch (JsonException) + { + // Not JSON: nothing to record. The program reads the same buffered content. + } + } + return response; + } + + private static void Collect(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.NameEquals("Url") && property.Value.ValueKind == JsonValueKind.String) + FileUrls.TryAdd(property.Value.GetString()!, 0); + else + Collect(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + Collect(item); + } + } + } +} diff --git a/.github/ci/StagingHost.cs b/.github/ci/StagingHost.cs deleted file mode 100644 index de96e79..0000000 --- a/.github/ci/StagingHost.cs +++ /dev/null @@ -1,33 +0,0 @@ -// CI only. This file is never part of an example or of the code in README.md. -// -// The Examples workflow (.github/workflows/examples.yml) compiles it into each example and README program -// it builds, through staging-host.targets. The module initializer runs before Main, so ConvertApi.ApiBaseUri -// already points at the staging API when the program creates its client, and the code people copy -// stays exactly as it is in the repository. -namespace ConvertApiCi -{ - internal static class StagingHost - { - [System.Runtime.CompilerServices.ModuleInitializer] - internal static void UseBaseUriFromEnvironment() - { - var baseUri = System.Environment.GetEnvironmentVariable("CONVERTAPI_BASE_URI"); - if (string.IsNullOrEmpty(baseUri)) - return; - - ConvertApiDotNet.ConvertApi.ApiBaseUri = baseUri; - - // The workflow fails a run that does not print this line, so a program built without this - // file cannot pass while it calls the production API. - System.Console.Error.WriteLine("[ci] ConvertApi.ApiBaseUri = " + baseUri); - - // A README program does not catch errors, and the crash message leaves out what the API said, - // such as which parameter it rejected. Print that too. - System.AppDomain.CurrentDomain.UnhandledException += (_, e) => - { - if (e.ExceptionObject is ConvertApiDotNet.Exceptions.ConvertApiException error) - System.Console.Error.WriteLine($"[ci] The API answered {(int)error.StatusCode}: {error.Response}"); - }; - } - } -} diff --git a/.github/ci/check_csharp.py b/.github/ci/check_csharp.py index 4ff9738..56cb3a3 100755 --- a/.github/ci/check_csharp.py +++ b/.github/ci/check_csharp.py @@ -1,20 +1,21 @@ #!/usr/bin/env python3 -"""Runs the C# examples and the C# code in README.md against the ConvertAPI staging API. +"""Runs the C# examples and the C# code in README.md against the ConvertAPI API. -CI only: the Examples workflow (.github/workflows/examples.yml) calls it. MSBuild imports -staging-host.targets into every program that job builds, so each program calls CONVERTAPI_BASE_URI -without a change to the code people copy. +CI only: the Examples workflow (.github/workflows/examples.yml) calls it. MSBuild imports ci-host.targets +into every program that job builds, which compiles in CiHost.cs: the programs run as people copy them, +and the files they upload or get back are deleted from the server when they exit. check_csharp.py examples [NAME ...] run every project in Examples/, or the named ones (built beforehand) check_csharp.py readme-build --work DIR make a console project of each C# block in README.md and build it check_csharp.py readme-run --work DIR run the quick start and the "... in C#" task blocks -The runs need CONVERTAPI_TOKEN (a staging API token) and CONVERTAPI_BASE_URI. The staging API answers 503 -in bursts, so a failed run is tried again up to twice, CI_RETRY_DELAY seconds apart (30 by default). -Only the Python 3 standard library is used. To run it on your machine, from the repository root: +The runs need CONVERTAPI_TOKEN and convert real files on that token's account: about 40 conversions for +all of them. A failed run is tried again up to twice, CI_RETRY_DELAY seconds apart (30 by default), because +the API can answer 503 for a moment. Only the Python 3 standard library is used. To run it on your +machine, from the repository root: - export CONVERTAPI_TOKEN=your-staging-token CONVERTAPI_BASE_URI=https://stag-v2.convertapi.com - export CustomAfterMicrosoftCommonTargets="$PWD/.github/ci/staging-host.targets" + export CONVERTAPI_TOKEN=your-api-token + export CustomAfterMicrosoftCommonTargets="$PWD/.github/ci/ci-host.targets" dotnet build ConvertApi.sln -c Release && python3 .github/ci/check_csharp.py examples """ @@ -41,8 +42,11 @@ RETRY_DELAY = int(os.environ.get("CI_RETRY_DELAY", "30")) RUN_TIMEOUT = 300 # seconds for one run of one program -# StagingHost.cs prints this line before Main. A run without it did not call the staging API. -STAGING_LINE = "[ci] ConvertApi.ApiBaseUri = " +# CiHost.cs prints this line before Main. A run without it was built without CiHost.cs, so nothing +# deleted its files from the server. +CI_LINE = "[ci] ConvertApi.ApiBaseUri = " +# ... and this one when the program exits. +DELETED_LINE = re.compile(r"^\[ci\] Deleted (\d+) of (\d+) files from the server$", re.MULTILINE) # The examples catch ConvertApiException, print it and still exit with 0, so these lines mark a failed call. ERROR_LINE = re.compile( @@ -59,6 +63,9 @@ # Account fields that must never reach the public log. The token itself is checked by value. SECRET_FIELD = re.compile(r"(?i)\b(secret|api[ _-]?key)\s*[=:]") +# Account details that RetrieveUserInformation prints. They are not secrets, but they stay out of the public log. +ACCOUNT_FIELD = re.compile(r"^(FullName|Email|ConversionsTotal|ConversionsConsumed|SecondsLeft)\s*[=:]") + # README.md names its input files document.docx, page.html and so on; the runs copy these samples in under those names. SAMPLE_FOR_EXTENSION = { ".docx": "test.docx", @@ -68,7 +75,7 @@ ".xlsx": "test.xlsx", } -# The README blocks that run against staging: the quick start program and the blocks under the +# The README blocks that run against the API: the quick start program and the blocks under the # "... in C#" task headings. Every other block is only built. QUICK_START_HEADING = "Quick start" RUN_HEADING = re.compile(r" in C#$") @@ -115,8 +122,11 @@ def print_program_output(text, token): print(f"::stop-commands::{marker}") for line in lines: line = "".join(ch if ch.isprintable() or ch == "\t" else "?" for ch in line) + account = ACCOUNT_FIELD.match(line) if SECRET_FIELD.search(line): line = "(line hidden: it names a secret field)" + elif account: + line = f"{account.group(1)}=(hidden in this public log)" print(" " + (line if len(line) <= 300 else line[:300] + " ...")) print(f"::{marker}::") @@ -136,14 +146,18 @@ def table_cell(text): # Running a program -def require_staging(): - missing = [name for name in ("CONVERTAPI_TOKEN", "CONVERTAPI_BASE_URI") if not os.environ.get(name)] - if missing: - annotate("error", f"{' and '.join(missing)} not set, so nothing can run against the staging API") +def require_token(): + if not os.environ.get("CONVERTAPI_TOKEN"): + annotate("error", "CONVERTAPI_TOKEN is not set, so nothing can run against the API") sys.exit(1) return os.environ["CONVERTAPI_TOKEN"] +def program_output(text): + """The program's own output, without the lines CiHost.cs adds.""" + return "\n".join(line for line in text.splitlines() if not line.startswith("[ci] ")) + + def find_leaks(text, token): leaks = [] if token and token in text: @@ -178,11 +192,16 @@ def run_with_retries(label, command, cwd, check, token, env=None, reset=None): print(f"::group::{label}: passed ({seconds:.1f} s{f', attempt {attempt}' if attempt > 1 else ''})") print_program_output(text, token) print("::endgroup::") + # Not a failure: the API deletes what is left after 3 hours anyway. + deleted = DELETED_LINE.search(text) + if not deleted or deleted.group(1) != deleted.group(2): + annotate("warning", f"{label}: {deleted.group(0)[5:] if deleted else 'CiHost.cs did not report deleting its files'}", + title="Files left on the server") return True, attempt, [] print(f"{label}: FAILED on attempt {attempt} of {ATTEMPTS} ({seconds:.1f} s): {'; '.join(problems)}") print_program_output(text, token) - # A leak is in the code, not in the staging API, so another attempt would only print it again. + # A leak is in the code, not in the API, so another attempt would only print it again. if leaks or attempt == ATTEMPTS: break annotate("warning", f"{label} failed on attempt {attempt}: {'; '.join(problems)}. Trying again in {RETRY_DELAY} s.", @@ -197,8 +216,8 @@ def common_problems(exit_code, text): problems.append(f"timed out after {RUN_TIMEOUT} s") elif exit_code != 0: problems.append(f"exit code {exit_code}") - if STAGING_LINE not in text: - problems.append("it did not run against the staging API: StagingHost.cs was not compiled in") + if CI_LINE not in text: + problems.append("it did not print the [ci] line: .github/ci/CiHost.cs was not compiled in") return problems @@ -207,7 +226,7 @@ def common_problems(exit_code, text): def run_examples(args): - token = require_staging() + token = require_token() projects = sorted(p for p in EXAMPLES.glob("*/*.csproj") if not args.names or p.stem in args.names) if not projects or (args.names and len(projects) != len(set(args.names))): annotate("error", f"No example projects found in {EXAMPLES} for {' '.join(args.names) or 'any name'}") @@ -243,11 +262,11 @@ def check(exit_code, text, expected=expected): for name, _, _, problems in failed: annotate("error", f"{name}: {'; '.join(problems)}", title="Example failed", file=f"Examples/{name}/Program.cs") - print(f"\nExamples: {len(results) - len(failed)} of {len(results)} passed against the staging API.") + print(f"\nExamples: {len(results) - len(failed)} of {len(results)} passed against the API.") rows = [f"| {name} | {'passed' if passed else 'FAILED: ' + table_cell('; '.join(problems))} | {attempts} |" for name, passed, attempts, problems in results] - write_summary("### Examples on the staging API\n\n| Example | Result | Attempts |\n|---|---|---|\n" + "\n".join(rows) + "\n") + write_summary("### Examples\n\n| Example | Result | Attempts |\n|---|---|---|\n" + "\n".join(rows) + "\n") return 1 if failed else 0 @@ -437,7 +456,7 @@ def project_files(folder): def readme_run(args): - token = require_staging() + token = require_token() work = Path(args.work).resolve() if not (work / "blocks.json").exists(): annotate("error", f"{work / 'blocks.json'} is missing: run readme-build first") @@ -475,7 +494,7 @@ def check(exit_code, text, folder=folder, before=before, saves_files=e["saves_fi if saves_files: if not [p for p in project_files(folder) - before if p.stat().st_size > 0]: problems.append("it saved no file") - elif not text.replace(STAGING_LINE, "").strip(): + elif not program_output(text).strip(): problems.append("it printed nothing") return problems @@ -485,12 +504,12 @@ def check(exit_code, text, folder=folder, before=before, saves_files=e["saves_fi failed = [r for r in results if not r[1]] for e, _, _, problems in failed: - annotate("error", "; ".join(problems), title="README block failed on the staging API", file="README.md", line=e["line"]) - print(f"\nREADME.md: {len(results) - len(failed)} of {len(results)} programs passed against the staging API.") + annotate("error", "; ".join(problems), title="README block failed against the API", file="README.md", line=e["line"]) + print(f"\nREADME.md: {len(results) - len(failed)} of {len(results)} programs passed against the API.") rows = [f"| {e['line']} | {table_cell(e['heading'])} | {'passed' if passed else 'FAILED: ' + table_cell('; '.join(problems))} | {attempts} |" for e, passed, attempts, problems in results] - write_summary("### README.md programs on the staging API\n\n| Line | Section | Result | Attempts |\n|---|---|---|---|\n" + write_summary("### README.md programs\n\n| Line | Section | Result | Attempts |\n|---|---|---|---|\n" + "\n".join(rows) + "\n") return 1 if failed or not results else 0 diff --git a/.github/ci/staging-host.targets b/.github/ci/ci-host.targets similarity index 58% rename from .github/ci/staging-host.targets rename to .github/ci/ci-host.targets index eb5d278..7830cc0 100644 --- a/.github/ci/staging-host.targets +++ b/.github/ci/ci-host.targets @@ -4,10 +4,11 @@ file's path, and MSBuild then imports it into every project that job builds. Nothing in the repository imports it, so a normal build, and every folder people copy, is unchanged. - It compiles StagingHost.cs into the programs (the examples and the projects made from README.md), never - into the library or the unit tests, so those programs call the API at CONVERTAPI_BASE_URI. + It compiles CiHost.cs into the programs (the examples and the projects made from README.md), never into + the library or the unit tests. CiHost.cs deletes the files each program uploads or gets back, when the + program exits. --> - +
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 63c3a7c..37a3f7b 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -1,28 +1,44 @@ name: Examples run-name: >- - ${{ github.event_name == 'pull_request' && format('Running the examples and README code of #{0} on staging', github.event.pull_request.number) - || format('Running the examples and README code of {0} on staging', github.ref_name) }} + ${{ github.event_name == 'pull_request' && format('Running the examples and README code of #{0}', github.event.pull_request.number) + || format('Running the examples and README code of {0}', github.ref_name) }} # The examples in Examples/ and the C# code in README.md are what people copy, so this workflow runs them -# against the staging API: a broken example or README snippet fails here instead of for a user. -# build.yml only compiles the examples and never builds the README code. +# against the ConvertAPI API with the CI account's token (the CONVERTAPI_CI_TOKEN secret): a broken example +# or README snippet fails here instead of for a user. build.yml only compiles the examples and never builds +# the README code. # -# The programs run exactly as people copy them. Every project this job builds imports -# .github/ci/staging-host.targets (through the CustomAfterMicrosoftCommonTargets variable below), which -# compiles .github/ci/StagingHost.cs into the programs: a module initializer that points -# ConvertApi.ApiBaseUri at CONVERTAPI_BASE_URI before Main runs. .github/ci/check_csharp.py then runs -# them, and the job fails when +# The programs run exactly as people copy them, against the same API address. Every project this job +# builds imports .github/ci/ci-host.targets (through the CustomAfterMicrosoftCommonTargets variable below), +# which compiles in .github/ci/CiHost.cs. It deletes the files each program uploads or gets back when the +# program exits, and prints what the API answered when a program crashes. .github/ci/check_csharp.py runs +# the programs, and the job fails when # - an example exits with an error or prints an API error it caught (ExceptionHandling must print its 400); # - a C# block in README.md does not build as its own console project against the library source; -# - the quick start or a "... in C#" task block in README.md fails on staging or saves no file; +# - the quick start or a "... in C#" task block in README.md fails or saves no file; # - any output contains the API token or a Secret or ApiKey field, because these logs are public; -# - a program did not print StagingHost's line, so it may have called the production API. -# A run that fails is tried again up to twice, 30 s apart, because the staging API answers 503 in bursts. +# - a program was built without CiHost.cs. +# A run that fails is tried again up to twice, 30 s apart. One job converts about 40 files. on: + # Only for changes that can break an example or a README block, to keep the CI account's conversions low. pull_request: branches: [main] + paths: + - ConvertApi/** + - Examples/** + - README.md + - global.json + - .github/ci/** + - .github/workflows/examples.yml push: branches: [main] + paths: + - ConvertApi/** + - Examples/** + - README.md + - global.json + - .github/ci/** + - .github/workflows/examples.yml # The API can change without a commit here (a converter parameter goes away, a site starts blocking # the renderer), so the examples also run every Monday. schedule: @@ -38,13 +54,14 @@ concurrency: cancel-in-progress: true env: - CONVERTAPI_BASE_URI: https://stag-v2.convertapi.com DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 jobs: examples: name: examples (${{ matrix.os }}) + # A pull request from a fork gets no secrets, so it is skipped; its changes run on main after the merge. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -53,8 +70,8 @@ jobs: os: [ubuntu-latest, macos-latest] env: # MSBuild reads environment variables as properties, so every project built in this job imports the - # staging switch. Nothing in the repository imports it. - CustomAfterMicrosoftCommonTargets: ${{ github.workspace }}/.github/ci/staging-host.targets + # CI host. Nothing in the repository imports it. + CustomAfterMicrosoftCommonTargets: ${{ github.workspace }}/.github/ci/ci-host.targets steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -70,55 +87,53 @@ jobs: # Each C# block becomes Program.cs of a `dotnet new console` project that references the library # source, so README.md is checked against what the next release ships. A fragment is spliced into the - # quick start program; one that does not fit is listed as skipped. Needs no token, so it also runs - # for pull requests from forks. + # quick start program; one that does not fit is listed as skipped. - name: Build the C# code in README.md id: build-readme if: ${{ !cancelled() }} run: python3 .github/ci/check_csharp.py readme-build --work "$RUNNER_TEMP/readme" - - name: Check the staging token + # Dependabot runs read the Dependabot secret of the same name, so the token is set both ways. + - name: Check the API token id: token if: ${{ !cancelled() }} env: - CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} - FROM_FORK: ${{ github.event.pull_request.head.repo.fork }} + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_CI_TOKEN }} run: | set -euo pipefail if [ -z "$CONVERTAPI_TOKEN" ]; then - if [ "$FROM_FORK" = true ]; then - echo "::error::A pull request from a fork gets no secrets, so its examples cannot run on staging. They run on main after the merge." - elif [ "$GITHUB_ACTOR" = "dependabot[bot]" ]; then - echo "::error::Dependabot runs get only Dependabot secrets, so CONVERTAPI_STAGING_TOKEN must also be a Dependabot secret." + if [ "$GITHUB_ACTOR" = "dependabot[bot]" ]; then + echo "::error::Dependabot runs get only Dependabot secrets, so CONVERTAPI_CI_TOKEN must also be a Dependabot secret." else - echo "::error::The repository has no CONVERTAPI_STAGING_TOKEN secret, so the examples cannot run on staging." + echo "::error::The repository has no CONVERTAPI_CI_TOKEN secret, so the examples cannot run." fi exit 1 fi status=$(curl -sS --retry 5 --retry-delay 10 -o "$RUNNER_TEMP/user.json" -w '%{http_code}' \ - -H "Authorization: Bearer $CONVERTAPI_TOKEN" "$CONVERTAPI_BASE_URI/user") + -H "Authorization: Bearer $CONVERTAPI_TOKEN" https://v2.convertapi.com/user) if [ "$status" != 200 ]; then - echo "::error::The staging API answered $status to CONVERTAPI_STAGING_TOKEN (GET /user)." + echo "::error::The API answered $status to CONVERTAPI_CI_TOKEN (GET /user)." exit 1 fi - # RetrieveUserInformation prints the account's name and email. This log is public, so mask both. + # RetrieveUserInformation prints the account's name and email. This log is public, so mask both + # wherever they appear. check_csharp.py also hides the account fields that example prints. for field in FullName Email; do value=$(jq -r --arg field "$field" '.[$field] // empty' "$RUNNER_TEMP/user.json") if [ -n "$value" ]; then echo "::add-mask::$value"; fi done - jq -r '"Staging account: \(.ConversionsConsumed) of \(.ConversionsTotal) conversions used"' "$RUNNER_TEMP/user.json" rm "$RUNNER_TEMP/user.json" + echo "The API accepts CONVERTAPI_CI_TOKEN." - - name: Run the examples on staging + - name: Run the examples if: ${{ !cancelled() && steps.token.outcome == 'success' && steps.build-examples.outcome == 'success' }} env: - CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_CI_TOKEN }} run: python3 .github/ci/check_csharp.py examples # The quick start and the blocks under the "... in C#" task headings, with the sample files from # Examples/TestFiles copied in under the names they use. The other blocks are only built. - - name: Run the README quick start and task code on staging + - name: Run the README quick start and task code if: ${{ !cancelled() && steps.token.outcome == 'success' }} env: - CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_STAGING_TOKEN }} + CONVERTAPI_TOKEN: ${{ secrets.CONVERTAPI_CI_TOKEN }} run: python3 .github/ci/check_csharp.py readme-run --work "$RUNNER_TEMP/readme" From 648c1ef2ea8b099f64b12c69baad47d5db32d11d Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 11 Nov 2026 11:21:56 +0200 Subject: [PATCH 3/4] ci: catch quoted Secret and ApiKey keys in the leak check A JSON line such as "Secret": "..." has a quote between the key and the colon, so the pattern missed it, both in the leak check and in the log printer. Allow an optional quote after the key, and the same for the account fields the log hides. Co-Authored-By: Claude Opus 5.5 --- .github/ci/check_csharp.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/ci/check_csharp.py b/.github/ci/check_csharp.py index 56cb3a3..587b23f 100755 --- a/.github/ci/check_csharp.py +++ b/.github/ci/check_csharp.py @@ -60,11 +60,12 @@ "ExceptionHandling": re.compile(r"^Status Code: BadRequest$", re.MULTILINE), } -# Account fields that must never reach the public log. The token itself is checked by value. -SECRET_FIELD = re.compile(r"(?i)\b(secret|api[ _-]?key)\s*[=:]") +# Account fields that must never reach the public log, also as quoted JSON keys ("Secret": ...). +# The token itself is checked by value. +SECRET_FIELD = re.compile(r"""(?i)\b(secret|api[ _-]?key)["']?\s*[=:]""") # Account details that RetrieveUserInformation prints. They are not secrets, but they stay out of the public log. -ACCOUNT_FIELD = re.compile(r"^(FullName|Email|ConversionsTotal|ConversionsConsumed|SecondsLeft)\s*[=:]") +ACCOUNT_FIELD = re.compile(r"""^\s*["']?(FullName|Email|ConversionsTotal|ConversionsConsumed|SecondsLeft)["']?\s*[=:]""") # README.md names its input files document.docx, page.html and so on; the runs copy these samples in under those names. SAMPLE_FOR_EXTENSION = { From ebcea91d65f84b0dea80fd6ec4424212c90f957c Mon Sep 17 00:00:00 2001 From: Tomas Rutkauskas Date: Wed, 11 Nov 2026 11:39:57 +0200 Subject: [PATCH 4/4] ci: print no output of a run that leaks, stop timed-out runs fully - When a run's output contains the token or a Secret or ApiKey field, none of it is printed: a value can sit on a line of its own after its key. - A run that times out is stopped with its whole process group. `dotnet run` starts the program as its own child, which could otherwise go on calling the API while the next attempt runs. Co-Authored-By: Claude Opus 5.5 --- .github/ci/check_csharp.py | 39 ++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/ci/check_csharp.py b/.github/ci/check_csharp.py index 587b23f..53e576a 100755 --- a/.github/ci/check_csharp.py +++ b/.github/ci/check_csharp.py @@ -25,6 +25,7 @@ import re import secrets import shutil +import signal import subprocess import sys import tempfile @@ -169,6 +170,27 @@ def find_leaks(text, token): return leaks +def run_once(command, cwd, env): + """Runs a program with stdin closed. Returns (exit code, or None after a timeout; stdout and stderr).""" + process = subprocess.Popen(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, start_new_session=True) + try: + output, _ = process.communicate(timeout=RUN_TIMEOUT) + return process.returncode, output + except subprocess.TimeoutExpired: + # `dotnet run` starts the program as its own child, so stop the whole process group: a program + # that timed out must not go on calling the API while the next attempt runs. + try: + os.killpg(process.pid, signal.SIGKILL) + except (AttributeError, ProcessLookupError): # no process groups on Windows + process.kill() + try: + output, _ = process.communicate(timeout=30) + except subprocess.TimeoutExpired: + output = b"" + return None, output + + def run_with_retries(label, command, cwd, check, token, env=None, reset=None): """Runs a program until check() finds no problem, at most ATTEMPTS times. Returns (passed, attempts, problems).""" problems = [] @@ -176,14 +198,7 @@ def run_with_retries(label, command, cwd, check, token, env=None, reset=None): if reset: reset() started = time.monotonic() - try: - completed = subprocess.run( - command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=RUN_TIMEOUT, - ) - exit_code, raw = completed.returncode, completed.stdout - except subprocess.TimeoutExpired as e: - exit_code, raw = None, e.stdout or b"" + exit_code, raw = run_once(command, cwd, env) seconds = time.monotonic() - started text = raw.decode("utf-8", "replace") @@ -201,9 +216,13 @@ def run_with_retries(label, command, cwd, check, token, env=None, reset=None): return True, attempt, [] print(f"{label}: FAILED on attempt {attempt} of {ATTEMPTS} ({seconds:.1f} s): {'; '.join(problems)}") + if leaks: + # None of the output is printed: a secret's value can sit on a line of its own. A leak is in the + # code, not in the API, so another attempt would not help either. + print(" (output not printed because it contains a secret; run the program locally to see it)") + break print_program_output(text, token) - # A leak is in the code, not in the API, so another attempt would only print it again. - if leaks or attempt == ATTEMPTS: + if attempt == ATTEMPTS: break annotate("warning", f"{label} failed on attempt {attempt}: {'; '.join(problems)}. Trying again in {RETRY_DELAY} s.", title="Retrying a failed run")