From b9a402a324b4651d53f466248fa4b6e3f19add50 Mon Sep 17 00:00:00 2001 From: Michael Villari <147255440+mikevillari@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:22:43 -0400 Subject: [PATCH 1/5] fix(ci): fail when Markdown enumeration fails or is empty --- scripts/markdown-link-files.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/markdown-link-files.sh b/scripts/markdown-link-files.sh index 988f7efd..0502f360 100755 --- a/scripts/markdown-link-files.sh +++ b/scripts/markdown-link-files.sh @@ -36,6 +36,13 @@ while IFS= read -r path || [[ -n "$path" ]]; do exclusions+=("$path") done < "$exclusions_file" +file_list="$(mktemp)" +trap 'rm -f "$file_list"' EXIT +if ! git -C "$repo_root" ls-files -z -- '*.md' > "$file_list"; then + printf 'markdown-link-files: failed to enumerate tracked Markdown files\n' >&2 + exit 1 +fi +count=0 while IFS= read -r -d '' path; do skip=false for excluded in "${exclusions[@]}"; do @@ -44,5 +51,12 @@ while IFS= read -r -d '' path; do break fi done - [[ "$skip" == true ]] || printf '%s\0' "$path" -done < <(git -C "$repo_root" ls-files -z -- '*.md') + if [[ "$skip" != true ]]; then + printf '%s\0' "$path" + count=$((count + 1)) + fi +done < "$file_list" +if [[ "$count" -eq 0 ]]; then + printf 'markdown-link-files: no tracked Markdown files to check\n' >&2 + exit 1 +fi From fc495d76ee4647cb294ce593c64190084b614bc8 Mon Sep 17 00:00:00 2001 From: Michael Villari <147255440+mikevillari@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:24:24 -0400 Subject: [PATCH 2/5] test(ci): reject failed and empty Markdown consumers --- tests/release/markdown-link-files.test.sh | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/release/markdown-link-files.test.sh b/tests/release/markdown-link-files.test.sh index a1251204..64a73bab 100755 --- a/tests/release/markdown-link-files.test.sh +++ b/tests/release/markdown-link-files.test.sh @@ -63,4 +63,63 @@ grep -Fq 'excluded path is not a tracked Markdown file: docs/missing.md' <<< "$o exit 1 } +# Remove exclusions so a failed enumeration reaches git ls-files itself. +: > "$fixture/scripts/markdown-link-exclusions.txt" +mv "$fixture/.git" "$fixture/git-backup" +if output="$("$fixture/scripts/markdown-link-files.sh" 2>&1)"; then + printf 'markdown-link-files: failed git enumeration unexpectedly passed\n' >&2 + exit 1 +fi +grep -Fq 'markdown-link-files' <<< "$output" +mv "$fixture/git-backup" "$fixture/.git" + +git -C "$fixture" rm --cached -q -- '*.md' +if output="$("$fixture/scripts/markdown-link-files.sh" 2>&1)"; then + printf 'markdown-link-files: empty derived set unexpectedly passed\n' >&2 + exit 1 +fi +grep -Fq 'markdown-link-files' <<< "$output" + +# Exercise the real consumer bodies with an offline checker and producer. +python3 - "$repo_root" "$fixture" <<'PYTEST' +import os +from pathlib import Path +import re +import subprocess +import sys + +root, fixture = map(Path, sys.argv[1:]) +local = (root / "scripts/ci-local.sh").read_text() +body = re.search(r"hygiene_markdown_link_check\(\) \(\n(.*?)\n\)", local, re.S).group(1) +workflow = (root / ".github/workflows/ci.yml").read_text() +bodies = {"local": body} +for kind in ("internal", "external"): + match = re.search(r" - name: Check " + kind + r" markdown links\n run: \|\n(.*?)(?=\n - name:)", workflow, re.S) + bodies[kind] = "\n".join(line[10:] for line in match.group(1).splitlines()) +bin_dir = fixture / "bin" +bin_dir.mkdir() +checker = bin_dir / "markdown-link-check" +checker.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "${@: -1}" >> "$CHECK_LOG"\n') +checker.chmod(0o755) +env = dict(os.environ, PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], CHECK_LOG=str(fixture / "checked")) +producer = fixture / "scripts/markdown-link-files.sh" +for scenario, script in { + "failed": "exit 1", + "empty": "exit 0", + "partial failure": "printf 'README.md\\0'; exit 1", + "valid": "printf 'README.md\\0docs/a guide.md\\0'", +}.items(): + producer.write_text("#!/usr/bin/env bash\n" + script + "\n") + for name, body in bodies.items(): + log = fixture / "checked" + log.unlink(missing_ok=True) + result = subprocess.run(["bash", "-euo", "pipefail", "-c", 'repo_root="$1"\n' + body, "consumer", str(fixture)], cwd=fixture, env=env, capture_output=True, text=True) + if scenario != "valid": + assert result.returncode != 0, f"{name} consumer accepted {scenario} producer" + else: + assert result.returncode == 0, (name, result.stderr) + expected = ["README.md", "docs/a guide.md"] * (2 if name == "local" else 1) + assert log.read_text().splitlines() == expected, f"{name} consumer did not check every file" +PYTEST + printf 'markdown-link-files: derived files and exclusions validated.\n' From dba28f2c711e32caf7758250c74451416684ba38 Mon Sep 17 00:00:00 2001 From: Michael Villari <147255440+mikevillari@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:26:09 -0400 Subject: [PATCH 3/5] fix(ci): propagate Markdown producer failures locally --- scripts/ci-local.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 34ff4513..4ded7459 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -236,12 +236,22 @@ hygiene_markdownlint() ( hygiene_markdown_link_check() ( cd "$repo_root" || exit 1 + file_list="$(mktemp)" + trap 'rm -f "$file_list"' EXIT + scripts/markdown-link-files.sh > "$file_list" || exit 1 + count=0 while IFS= read -r -d '' f; do markdown-link-check --config .markdown-link-check-internal.json "$f" || exit 1 - done < <(scripts/markdown-link-files.sh) + count=$((count + 1)) + done < "$file_list" + [[ "$count" -gt 0 ]] || { printf 'markdown-link-files: no files checked\n' >&2; exit 1; } + scripts/markdown-link-files.sh --external > "$file_list" || exit 1 + count=0 while IFS= read -r -d '' f; do markdown-link-check --config .markdown-link-check.json "$f" || exit 1 - done < <(scripts/markdown-link-files.sh --external) + count=$((count + 1)) + done < "$file_list" + [[ "$count" -gt 0 ]] || { printf 'markdown-link-files: no files checked\n' >&2; exit 1; } ) hygiene_yamllint() ( From 147743910736b4c541923de5dd0c85a3a832b51f Mon Sep 17 00:00:00 2001 From: Michael Villari <147255440+mikevillari@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:28:26 -0400 Subject: [PATCH 4/5] fix(ci): reject failed or empty Markdown workflow inputs --- .github/workflows/ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fa20cb7..6759a51a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,12 +145,22 @@ jobs: - name: Check internal markdown links run: | + file_list="$(mktemp)" + trap 'rm -f "$file_list"' EXIT + scripts/markdown-link-files.sh > "$file_list" || exit 1 + count=0 while IFS= read -r -d '' file; do markdown-link-check --config .markdown-link-check-internal.json "$file" - done < <(scripts/markdown-link-files.sh) + count=$((count + 1)) + done < "$file_list" + [[ "$count" -gt 0 ]] || { printf 'markdown-link-files: no files checked\n' >&2; exit 1; } - name: Check external markdown links run: | + file_list="$(mktemp)" + trap 'rm -f "$file_list"' EXIT + scripts/markdown-link-files.sh --external > "$file_list" || exit 1 + count=0 while IFS= read -r -d '' file; do # Retry on transient network failure. `retryOn429` in the config only # covers rate limiting, and the failures actually seen here were @@ -172,7 +182,9 @@ jobs: echo "link check for $file failed (attempt $attempt/3); retrying in 10s" sleep 10 done - done < <(scripts/markdown-link-files.sh --external) + count=$((count + 1)) + done < "$file_list" + [[ "$count" -gt 0 ]] || { printf 'markdown-link-files: no files checked\n' >&2; exit 1; } - name: Lint GitHub issue templates run: yamllint .github/ISSUE_TEMPLATE/*.yml .github/workflows/*.yml From f9a01f0f1b3b231cfe5c7d1615195cc4bc5b0692 Mon Sep 17 00:00:00 2001 From: Michael Villari <147255440+mikevillari@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:15:42 -0400 Subject: [PATCH 5/5] test: tighten markdown producer diagnostics and shell parity --- tests/release/markdown-link-files.test.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/release/markdown-link-files.test.sh b/tests/release/markdown-link-files.test.sh index 64a73bab..6a433ab0 100755 --- a/tests/release/markdown-link-files.test.sh +++ b/tests/release/markdown-link-files.test.sh @@ -70,7 +70,7 @@ if output="$("$fixture/scripts/markdown-link-files.sh" 2>&1)"; then printf 'markdown-link-files: failed git enumeration unexpectedly passed\n' >&2 exit 1 fi -grep -Fq 'markdown-link-files' <<< "$output" +grep -Fq 'failed to enumerate tracked Markdown files' <<< "$output" mv "$fixture/git-backup" "$fixture/.git" git -C "$fixture" rm --cached -q -- '*.md' @@ -78,7 +78,7 @@ if output="$("$fixture/scripts/markdown-link-files.sh" 2>&1)"; then printf 'markdown-link-files: empty derived set unexpectedly passed\n' >&2 exit 1 fi -grep -Fq 'markdown-link-files' <<< "$output" +grep -Fq 'no tracked Markdown files to check' <<< "$output" # Exercise the real consumer bodies with an offline checker and producer. python3 - "$repo_root" "$fixture" <<'PYTEST' @@ -113,7 +113,9 @@ for scenario, script in { for name, body in bodies.items(): log = fixture / "checked" log.unlink(missing_ok=True) - result = subprocess.run(["bash", "-euo", "pipefail", "-c", 'repo_root="$1"\n' + body, "consumer", str(fixture)], cwd=fixture, env=env, capture_output=True, text=True) + # ci-local.sh enables strict mode; the workflow uses GitHub's default bash -e. + shell = ["bash", "-euo", "pipefail"] if name == "local" else ["bash", "-e"] + result = subprocess.run([*shell, "-c", 'repo_root="$1"\n' + body, "consumer", str(fixture)], cwd=fixture, env=env, capture_output=True, text=True) if scenario != "valid": assert result.returncode != 0, f"{name} consumer accepted {scenario} producer" else: