From cd52cd7fbf54e8ebdf797ca1b1039eb3eae03be2 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:45:09 +0000 Subject: [PATCH 01/29] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/lino-objects-codec/issues/41 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 57150dd..ac37274 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-05-10T19:22:27.543Z for PR creation at branch issue-35-03946ff48852 for issue https://github.com/link-foundation/lino-objects-codec/issues/35 # Updated: 2026-08-20T05:25:16.696Z -# Updated: 2026-08-20T06:10:07.182Z \ No newline at end of file +# Updated: 2026-08-20T06:10:07.182Z +# Updated: 2026-08-20T07:45:09.136Z \ No newline at end of file From cde96204616a201a8a6b19bec4daad07ffd088c2 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:52:00 +0000 Subject: [PATCH 02/29] docs(dev-log): preserve CI/CD evidence for issue #41 before any change --- dev/log/issues/41/pulls/42/README.md | 44 ++ .../pulls/42/analysis/registry-presence.txt | 17 + .../41/pulls/42/meta/issue-41-comments.json | 1 + dev/log/issues/41/pulls/42/meta/issue-41.json | 1 + .../41/pulls/42/meta/pr-42-comments.json | 1 + .../pulls/42/meta/pr-42-review-comments.json | 1 + dev/log/issues/41/pulls/42/meta/pr-42.json | 1 + .../pulls/42/meta/run-csharp-32345106283.json | 1 + .../41/pulls/42/meta/run-js-32345106180.json | 1 + .../pulls/42/meta/run-parity-32345476295.json | 1 + .../pulls/42/meta/run-python-32345106245.json | 1 + .../pulls/42/meta/run-rust-32345106299.json | 1 + dev/log/issues/41/pulls/42/meta/runs.json | 1 + .../42/templates/CI-CD-BEST-PRACTICES.md | 437 ++++++++++++++++++ 14 files changed, 509 insertions(+) create mode 100644 dev/log/issues/41/pulls/42/README.md create mode 100644 dev/log/issues/41/pulls/42/analysis/registry-presence.txt create mode 100644 dev/log/issues/41/pulls/42/meta/issue-41-comments.json create mode 100644 dev/log/issues/41/pulls/42/meta/issue-41.json create mode 100644 dev/log/issues/41/pulls/42/meta/pr-42-comments.json create mode 100644 dev/log/issues/41/pulls/42/meta/pr-42-review-comments.json create mode 100644 dev/log/issues/41/pulls/42/meta/pr-42.json create mode 100644 dev/log/issues/41/pulls/42/meta/run-csharp-32345106283.json create mode 100644 dev/log/issues/41/pulls/42/meta/run-js-32345106180.json create mode 100644 dev/log/issues/41/pulls/42/meta/run-parity-32345476295.json create mode 100644 dev/log/issues/41/pulls/42/meta/run-python-32345106245.json create mode 100644 dev/log/issues/41/pulls/42/meta/run-rust-32345106299.json create mode 100644 dev/log/issues/41/pulls/42/meta/runs.json create mode 100644 dev/log/issues/41/pulls/42/templates/CI-CD-BEST-PRACTICES.md diff --git a/dev/log/issues/41/pulls/42/README.md b/dev/log/issues/41/pulls/42/README.md new file mode 100644 index 0000000..66c7632 --- /dev/null +++ b/dev/log/issues/41/pulls/42/README.md @@ -0,0 +1,44 @@ +# Development log — issue #41 / pull request #42 + +> Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all +> +> - Issue: https://github.com/link-foundation/lino-objects-codec/issues/41 +> - Pull request: https://github.com/link-foundation/lino-objects-codec/pull/42 + +This folder holds the **evidence collected before any change was made**, plus the analysis +derived from it. Nothing here is generated after the fixes; the logs are the pristine +GitHub Actions output of the runs the issue points at. + +## Layout + +| Path | Contents | +| --- | --- | +| `ci-logs/` | Full `gh run view --log` output of every run referenced by the issue | +| `meta/` | Run/job/step JSON, issue and pull request metadata, comment dumps | +| `templates/` | The upstream CI/CD best-practices document used as the yardstick | +| `analysis/` | Registry probes and derived analysis | +| `ANALYSIS.md` | Timeline, requirement list, root causes, solution plans | + +## Collected runs + +| Workflow | Run | Commit | Conclusion | Log | +| --- | --- | --- | --- | --- | +| Python CI/CD | [32345106245](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245) | `41e3f4a` | failure | `ci-logs/python-32345106245.log` | +| C# CI/CD | [32345106283](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283) | `41e3f4a` | failure | `ci-logs/csharp-32345106283.log` | +| JavaScript CI/CD | [32345106180](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180) | `41e3f4a` | success | `ci-logs/js-32345106180.log` | +| Rust CI/CD | [32345106299](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299) | `41e3f4a` | success | `ci-logs/rust-32345106299.log` | +| Cross-Language Parity | [32345476295](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345476295) | `cd52cd7` | success | `ci-logs/parity-32345476295.log` | + +The two green runs are collected on purpose: the issue asks for **false positives** and +**warnings**, and both live in runs that ended green. + +## How the evidence was collected + +```bash +gh run view --repo link-foundation/lino-objects-codec --log > ci-logs/-.log +gh run view --repo link-foundation/lino-objects-codec --json databaseId,name,status,conclusion,createdAt,updatedAt,headSha,jobs \ + > meta/run--.json +``` + +Registry state was probed with plain `curl` against the four public registry APIs; the +output is preserved verbatim in `analysis/registry-presence.txt`. diff --git a/dev/log/issues/41/pulls/42/analysis/registry-presence.txt b/dev/log/issues/41/pulls/42/analysis/registry-presence.txt new file mode 100644 index 0000000..c6a8c39 --- /dev/null +++ b/dev/log/issues/41/pulls/42/analysis/registry-presence.txt @@ -0,0 +1,17 @@ +# Registry presence probe (captured 2026-08-20T07:49:23Z) + +## NuGet Lino.Objects.Codec index.json +{ + "versions": [ + "0.2.0" + ] +} +## NuGet 0.2.0 nuspec status +200 +## PyPI lino-objects-codec +404 +## npm lino-objects-codec +latest: {'latest': '0.5.0'} +versions: ['0.3.1', '0.3.5', '0.3.6', '0.4.0', '0.5.0'] +## crates.io lino-objects-codec +0.4.0 ['0.4.0', '0.3.0', '0.2.1', '0.2.0'] diff --git a/dev/log/issues/41/pulls/42/meta/issue-41-comments.json b/dev/log/issues/41/pulls/42/meta/issue-41-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/issue-41-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/41/pulls/42/meta/issue-41.json b/dev/log/issues/41/pulls/42/meta/issue-41.json new file mode 100644 index 0000000..fbb4e38 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/issue-41.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"### Recent CI/CD runs on `main`\n\n| Workflow | Status | Conclusion | Commit | Run |\n| --- | --- | --- | --- | --- |\n| Python CI/CD | completed | failure | `41e3f4a` | [run](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245) |\n| JavaScript CI/CD | completed | success | `41e3f4a` | [run](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180) |\n| Rust CI/CD | completed | success | `41e3f4a` | [run](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299) |\n| C# CI/CD | completed | failure | `41e3f4a` | [run](https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283) |\n| Tests | completed | success | `a15a617` | [run](https://github.com/link-foundation/lino-objects-codec/actions/runs/20414241826) |\n\nUse all the best practices from CI/CD templates (check full file tree to compare for all GitHub workflow and CI/CD scripts file), if the same issue is found in template report issue also in templates:\n\n- https://github.com/link-foundation/js-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/python-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/rust-ai-driven-development-pipeline-template\n- https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template\n\nWe should compare all files, so we don't have more CI/CD errors in the future and reuse all the best practices from these templates.\n\nFollow the CI/CD best practices collected in [https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md](https://github.com/link-assistant/hive-mind/blob/main/docs/CI-CD-BEST-PRACTICES.md).\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.\n\n---\n\n
\nContext collected by /fix --ci-cd\n\n- **Repository:** [link-foundation/lino-objects-codec](https://github.com/link-foundation/lino-objects-codec)\n- **Default branch:** `main`\n- **Latest commit:** `86f6ed3` ([commit](https://github.com/link-foundation/lino-objects-codec/commit/86f6ed3cee67dbe5dbedba0e62ce1553083aad4a)) — rust-v0.4.0\n- **CI/CD runs found:** 5 (2 not passing)\n\n**Detected languages**\n\n- **JavaScript** — 47.0%\n- **Python** — 23.5%\n- **Rust** — 15.2%\n- **C#** — 14.2%\n- **Jinja** — 0.1%\n\n**Recommended CI/CD templates**\n\nApply the best practices from these templates, in priority order (most-used language first):\n\n1. **JavaScript / TypeScript** — [link-foundation/js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) _(detected: JavaScript)_\n2. **Python** — [link-foundation/python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) _(detected: Python)_\n3. **Rust** — [link-foundation/rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) _(detected: Rust)_\n4. **C#** — [link-foundation/csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) _(detected: C#)_\n\nOther detected languages without a dedicated template: Jinja.\n\n
","createdAt":"2026-08-20T07:44:40Z","labels":[{"id":"LA_kwDOQWrSmc8AAAACP4o4IA","name":"bug","description":"Something isn't working","color":"d73a4a"}],"number":41,"state":"OPEN","title":"Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/41/pulls/42/meta/pr-42-comments.json b/dev/log/issues/41/pulls/42/meta/pr-42-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/pr-42-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/41/pulls/42/meta/pr-42-review-comments.json b/dev/log/issues/41/pulls/42/meta/pr-42-review-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/pr-42-review-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/dev/log/issues/41/pulls/42/meta/pr-42.json b/dev/log/issues/41/pulls/42/meta/pr-42.json new file mode 100644 index 0000000..74d662d --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/pr-42.json @@ -0,0 +1 @@ +{"baseRefName":"main","body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #41.\n\n### 📋 Issue Reference\nFixes #41\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","createdAt":"2026-08-20T07:45:18Z","headRefName":"issue-41-297a752a4939","isDraft":true,"number":42,"state":"OPEN","title":"[WIP] Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all"} diff --git a/dev/log/issues/41/pulls/42/meta/run-csharp-32345106283.json b/dev/log/issues/41/pulls/42/meta/run-csharp-32345106283.json new file mode 100644 index 0000000..7c0bed5 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/run-csharp-32345106283.json @@ -0,0 +1 @@ +{"conclusion":"failure","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106283,"headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","jobs":[{"completedAt":"2026-08-20T07:41:14Z","conclusion":"success","databaseId":96352100032,"name":"Lint and Format Check","startedAt":"2026-08-20T07:40:44Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:45Z","status":"completed"},{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:47Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:55Z","conclusion":"success","name":"Restore dependencies","number":4,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Check formatting","number":5,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:41:12Z","conclusion":"success","name":"Build with warnings as errors","number":6,"startedAt":"2026-08-20T07:41:07Z","status":"completed"},{"completedAt":"2026-08-20T07:41:12Z","conclusion":"success","name":"Post Setup .NET","number":11,"startedAt":"2026-08-20T07:41:12Z","status":"completed"},{"completedAt":"2026-08-20T07:41:13Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:41:12Z","status":"completed"},{"completedAt":"2026-08-20T07:41:13Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:41:13Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352100032"},{"completedAt":"2026-08-20T07:41:21Z","conclusion":"success","databaseId":96352100193,"name":"Test (.NET on macos-latest)","startedAt":"2026-08-20T07:40:44Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:48Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:45Z","status":"completed"},{"completedAt":"2026-08-20T07:40:50Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:48Z","status":"completed"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:40:50Z","status":"completed"},{"completedAt":"2026-08-20T07:41:10Z","conclusion":"success","name":"Restore dependencies","number":4,"startedAt":"2026-08-20T07:41:05Z","status":"completed"},{"completedAt":"2026-08-20T07:41:13Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-20T07:41:10Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Run tests","number":6,"startedAt":"2026-08-20T07:41:13Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"skipped","name":"Upload coverage (Ubuntu only)","number":8,"startedAt":"2026-08-20T07:41:18Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"success","name":"Post Setup .NET","number":15,"startedAt":"2026-08-20T07:41:18Z","status":"completed"},{"completedAt":"2026-08-20T07:41:19Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":16,"startedAt":"2026-08-20T07:41:18Z","status":"completed"},{"completedAt":"2026-08-20T07:41:19Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-20T07:41:19Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352100193"},{"completedAt":"2026-08-20T07:42:10Z","conclusion":"success","databaseId":96352100276,"name":"Test (.NET on windows-latest)","startedAt":"2026-08-20T07:40:47Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:52Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:50Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:52Z","status":"completed"},{"completedAt":"2026-08-20T07:41:33Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:47Z","conclusion":"success","name":"Restore dependencies","number":4,"startedAt":"2026-08-20T07:41:33Z","status":"completed"},{"completedAt":"2026-08-20T07:41:56Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-20T07:41:47Z","status":"completed"},{"completedAt":"2026-08-20T07:42:02Z","conclusion":"success","name":"Run tests","number":6,"startedAt":"2026-08-20T07:41:56Z","status":"completed"},{"completedAt":"2026-08-20T07:42:06Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:42:02Z","status":"completed"},{"completedAt":"2026-08-20T07:42:06Z","conclusion":"skipped","name":"Upload coverage (Ubuntu only)","number":8,"startedAt":"2026-08-20T07:42:06Z","status":"completed"},{"completedAt":"2026-08-20T07:42:06Z","conclusion":"success","name":"Post Setup .NET","number":15,"startedAt":"2026-08-20T07:42:06Z","status":"completed"},{"completedAt":"2026-08-20T07:42:08Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":16,"startedAt":"2026-08-20T07:42:06Z","status":"completed"},{"completedAt":"2026-08-20T07:42:09Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-20T07:42:08Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352100276"},{"completedAt":"2026-08-20T07:41:23Z","conclusion":"success","databaseId":96352100315,"name":"Test (.NET on ubuntu-latest)","startedAt":"2026-08-20T07:40:45Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:47Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:48Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:40:48Z","status":"completed"},{"completedAt":"2026-08-20T07:40:55Z","conclusion":"success","name":"Restore dependencies","number":4,"startedAt":"2026-08-20T07:40:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Run tests","number":6,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:41:09Z","status":"completed"},{"completedAt":"2026-08-20T07:41:20Z","conclusion":"success","name":"Upload coverage (Ubuntu only)","number":8,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:20Z","conclusion":"success","name":"Post Setup .NET","number":15,"startedAt":"2026-08-20T07:41:20Z","status":"completed"},{"completedAt":"2026-08-20T07:41:20Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":16,"startedAt":"2026-08-20T07:41:20Z","status":"completed"},{"completedAt":"2026-08-20T07:41:20Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-20T07:41:20Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352100315"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352100984,"name":"Changeset Check","startedAt":"2026-08-20T07:40:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352100984"},{"completedAt":"2026-08-20T07:42:42Z","conclusion":"success","databaseId":96352443357,"name":"Build Package","startedAt":"2026-08-20T07:42:13Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:42:16Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:42:14Z","status":"completed"},{"completedAt":"2026-08-20T07:42:17Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:42:16Z","status":"completed"},{"completedAt":"2026-08-20T07:42:27Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:42:17Z","status":"completed"},{"completedAt":"2026-08-20T07:42:31Z","conclusion":"success","name":"Restore dependencies","number":4,"startedAt":"2026-08-20T07:42:27Z","status":"completed"},{"completedAt":"2026-08-20T07:42:37Z","conclusion":"success","name":"Build","number":5,"startedAt":"2026-08-20T07:42:31Z","status":"completed"},{"completedAt":"2026-08-20T07:42:38Z","conclusion":"success","name":"Pack NuGet package","number":6,"startedAt":"2026-08-20T07:42:37Z","status":"completed"},{"completedAt":"2026-08-20T07:42:39Z","conclusion":"success","name":"Upload artifacts","number":7,"startedAt":"2026-08-20T07:42:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:39Z","conclusion":"success","name":"Post Setup .NET","number":13,"startedAt":"2026-08-20T07:42:39Z","status":"completed"},{"completedAt":"2026-08-20T07:42:40Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:42:39Z","status":"completed"},{"completedAt":"2026-08-20T07:42:40Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:42:40Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352443357"},{"completedAt":"2026-08-20T07:44:02Z","conclusion":"failure","databaseId":96352566632,"name":"Auto Release","startedAt":"2026-08-20T07:42:44Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:42:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:42:45Z","status":"completed"},{"completedAt":"2026-08-20T07:42:47Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:42:46Z","status":"completed"},{"completedAt":"2026-08-20T07:42:55Z","conclusion":"success","name":"Setup .NET","number":3,"startedAt":"2026-08-20T07:42:47Z","status":"completed"},{"completedAt":"2026-08-20T07:42:55Z","conclusion":"success","name":"Setup Node.js","number":4,"startedAt":"2026-08-20T07:42:55Z","status":"completed"},{"completedAt":"2026-08-20T07:42:55Z","conclusion":"success","name":"Check if version changed","number":5,"startedAt":"2026-08-20T07:42:55Z","status":"completed"},{"completedAt":"2026-08-20T07:42:56Z","conclusion":"success","name":"Download artifacts","number":6,"startedAt":"2026-08-20T07:42:55Z","status":"completed"},{"completedAt":"2026-08-20T07:42:59Z","conclusion":"success","name":"Publish to NuGet","number":7,"startedAt":"2026-08-20T07:42:56Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"failure","name":"Verify package on NuGet","number":8,"startedAt":"2026-08-20T07:42:59Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"skipped","name":"Create GitHub Release","number":9,"startedAt":"2026-08-20T07:44:00Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"skipped","name":"Post Setup Node.js","number":16,"startedAt":"2026-08-20T07:44:00Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"skipped","name":"Post Setup .NET","number":17,"startedAt":"2026-08-20T07:44:00Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":18,"startedAt":"2026-08-20T07:44:00Z","status":"completed"},{"completedAt":"2026-08-20T07:44:00Z","conclusion":"success","name":"Complete job","number":19,"startedAt":"2026-08-20T07:44:00Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352566632"},{"completedAt":"2026-08-20T07:42:42Z","conclusion":"skipped","databaseId":96352568298,"name":"Manual Release","startedAt":"2026-08-20T07:42:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106283/job/96352568298"}],"name":"C# CI/CD","status":"completed","updatedAt":"2026-08-20T07:44:03Z"} diff --git a/dev/log/issues/41/pulls/42/meta/run-js-32345106180.json b/dev/log/issues/41/pulls/42/meta/run-js-32345106180.json new file mode 100644 index 0000000..c27899d --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/run-js-32345106180.json @@ -0,0 +1 @@ +{"conclusion":"success","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106180,"headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","jobs":[{"completedAt":"2026-08-20T07:40:51Z","conclusion":"success","databaseId":96352099855,"name":"Detect Changes","startedAt":"2026-08-20T07:40:45Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:48Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Detect changes","number":3,"startedAt":"2026-08-20T07:40:48Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":6,"startedAt":"2026-08-20T07:40:49Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Complete job","number":7,"startedAt":"2026-08-20T07:40:49Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352099855"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352100601,"name":"Create Changeset PR","startedAt":"2026-08-20T07:40:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352100601"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352124653,"name":"Check for Manual Version Changes","startedAt":"2026-08-20T07:40:49Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352124653"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","databaseId":96352134037,"name":"Lint and Format Check","startedAt":"2026-08-20T07:40:53Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:55Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:54Z","status":"completed"},{"completedAt":"2026-08-20T07:40:56Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:40:56Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","name":"Run ESLint","number":5,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Check formatting","number":6,"startedAt":"2026-08-20T07:41:05Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Check code duplication","number":7,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Post Setup Node.js","number":13,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:41:07Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352134037"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","databaseId":96352134656,"name":"Test (Node.js on macos-latest)","startedAt":"2026-08-20T07:40:54Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:55Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:54Z","status":"completed"},{"completedAt":"2026-08-20T07:40:57Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:40:58Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:41:01Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:40:58Z","status":"completed"},{"completedAt":"2026-08-20T07:41:02Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:41:01Z","status":"completed"},{"completedAt":"2026-08-20T07:41:02Z","conclusion":"success","name":"Run example","number":6,"startedAt":"2026-08-20T07:41:02Z","status":"completed"},{"completedAt":"2026-08-20T07:41:02Z","conclusion":"success","name":"Post Setup Node.js","number":11,"startedAt":"2026-08-20T07:41:02Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:41:02Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:41:03Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352134656"},{"completedAt":"2026-08-20T07:41:28Z","conclusion":"success","databaseId":96352134667,"name":"Test (Node.js on windows-latest)","startedAt":"2026-08-20T07:40:55Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:57Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:56Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:41:08Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:22Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:41:08Z","status":"completed"},{"completedAt":"2026-08-20T07:41:23Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:41:22Z","status":"completed"},{"completedAt":"2026-08-20T07:41:24Z","conclusion":"success","name":"Run example","number":6,"startedAt":"2026-08-20T07:41:23Z","status":"completed"},{"completedAt":"2026-08-20T07:41:24Z","conclusion":"success","name":"Post Setup Node.js","number":11,"startedAt":"2026-08-20T07:41:24Z","status":"completed"},{"completedAt":"2026-08-20T07:41:26Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:41:24Z","status":"completed"},{"completedAt":"2026-08-20T07:41:26Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:41:26Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352134667"},{"completedAt":"2026-08-20T07:41:12Z","conclusion":"success","databaseId":96352134784,"name":"Test (Node.js on ubuntu-latest)","startedAt":"2026-08-20T07:40:56Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:58Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:58Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:08Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:41:08Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Run example","number":6,"startedAt":"2026-08-20T07:41:09Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Post Setup Node.js","number":11,"startedAt":"2026-08-20T07:41:09Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:41:09Z","status":"completed"},{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:41:09Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352134784"},{"completedAt":"2026-08-20T07:40:51Z","conclusion":"skipped","databaseId":96352134920,"name":"Check for Changesets","startedAt":"2026-08-20T07:40:52Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352134920"},{"completedAt":"2026-08-20T07:42:22Z","conclusion":"success","databaseId":96352277924,"name":"Release","startedAt":"2026-08-20T07:41:30Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:41:32Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:41:31Z","status":"completed"},{"completedAt":"2026-08-20T07:41:34Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:41:32Z","status":"completed"},{"completedAt":"2026-08-20T07:41:37Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:41:34Z","status":"completed"},{"completedAt":"2026-08-20T07:41:42Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:41:37Z","status":"completed"},{"completedAt":"2026-08-20T07:41:47Z","conclusion":"success","name":"Update npm for OIDC trusted publishing","number":5,"startedAt":"2026-08-20T07:41:42Z","status":"completed"},{"completedAt":"2026-08-20T07:41:47Z","conclusion":"success","name":"Check for changesets","number":6,"startedAt":"2026-08-20T07:41:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:47Z","conclusion":"skipped","name":"Merge multiple changesets","number":7,"startedAt":"2026-08-20T07:41:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:54Z","conclusion":"success","name":"Version packages and commit to main","number":8,"startedAt":"2026-08-20T07:41:47Z","status":"completed"},{"completedAt":"2026-08-20T07:42:12Z","conclusion":"success","name":"Publish to npm","number":9,"startedAt":"2026-08-20T07:41:54Z","status":"completed"},{"completedAt":"2026-08-20T07:42:16Z","conclusion":"success","name":"Create GitHub Release","number":10,"startedAt":"2026-08-20T07:42:12Z","status":"completed"},{"completedAt":"2026-08-20T07:42:20Z","conclusion":"success","name":"Format GitHub release notes","number":11,"startedAt":"2026-08-20T07:42:16Z","status":"completed"},{"completedAt":"2026-08-20T07:42:20Z","conclusion":"success","name":"Post Setup Node.js","number":21,"startedAt":"2026-08-20T07:42:20Z","status":"completed"},{"completedAt":"2026-08-20T07:42:20Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":22,"startedAt":"2026-08-20T07:42:20Z","status":"completed"},{"completedAt":"2026-08-20T07:42:21Z","conclusion":"success","name":"Complete job","number":23,"startedAt":"2026-08-20T07:42:21Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352277924"},{"completedAt":"2026-08-20T07:41:28Z","conclusion":"skipped","databaseId":96352279436,"name":"Instant Release","startedAt":"2026-08-20T07:41:28Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106180/job/96352279436"}],"name":"JavaScript CI/CD","status":"completed","updatedAt":"2026-08-20T07:42:23Z"} diff --git a/dev/log/issues/41/pulls/42/meta/run-parity-32345476295.json b/dev/log/issues/41/pulls/42/meta/run-parity-32345476295.json new file mode 100644 index 0000000..0b07e78 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/run-parity-32345476295.json @@ -0,0 +1 @@ +{"conclusion":"success","createdAt":"2026-08-20T07:45:21Z","databaseId":32345476295,"headSha":"cd52cd7fbf54e8ebdf797ca1b1039eb3eae03be2","jobs":[{"completedAt":"2026-08-20T07:45:29Z","conclusion":"success","databaseId":96353208331,"name":"Languages Change Together","startedAt":"2026-08-20T07:45:24Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:45:25Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:45:24Z","status":"completed"},{"completedAt":"2026-08-20T07:45:26Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:45:25Z","status":"completed"},{"completedAt":"2026-08-20T07:45:27Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:45:26Z","status":"completed"},{"completedAt":"2026-08-20T07:45:27Z","conclusion":"success","name":"Check parity-gate helper's own tests","number":4,"startedAt":"2026-08-20T07:45:27Z","status":"completed"},{"completedAt":"2026-08-20T07:45:27Z","conclusion":"success","name":"Enforce cross-language parity","number":5,"startedAt":"2026-08-20T07:45:27Z","status":"completed"},{"completedAt":"2026-08-20T07:45:27Z","conclusion":"success","name":"Post Setup Node.js","number":9,"startedAt":"2026-08-20T07:45:27Z","status":"completed"},{"completedAt":"2026-08-20T07:45:27Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":10,"startedAt":"2026-08-20T07:45:27Z","status":"completed"},{"completedAt":"2026-08-20T07:45:28Z","conclusion":"success","name":"Complete job","number":11,"startedAt":"2026-08-20T07:45:27Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345476295/job/96353208331"}],"name":"Cross-Language Parity","status":"completed","updatedAt":"2026-08-20T07:45:30Z"} diff --git a/dev/log/issues/41/pulls/42/meta/run-python-32345106245.json b/dev/log/issues/41/pulls/42/meta/run-python-32345106245.json new file mode 100644 index 0000000..81e1cb2 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/run-python-32345106245.json @@ -0,0 +1 @@ +{"conclusion":"failure","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106245,"headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","jobs":[{"completedAt":"2026-08-20T07:41:09Z","conclusion":"success","databaseId":96352099747,"name":"Lint and Format Check","startedAt":"2026-08-20T07:40:44Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:45Z","status":"completed"},{"completedAt":"2026-08-20T07:40:47Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:47Z","conclusion":"success","name":"Setup Python","number":3,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","name":"Run Ruff linting","number":5,"startedAt":"2026-08-20T07:41:05Z","status":"completed"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","name":"Check Ruff formatting","number":6,"startedAt":"2026-08-20T07:41:05Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Run mypy","number":7,"startedAt":"2026-08-20T07:41:05Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Check file size limit","number":8,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:06Z","conclusion":"success","name":"Post Setup Python","number":15,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":16,"startedAt":"2026-08-20T07:41:06Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Complete job","number":17,"startedAt":"2026-08-20T07:41:07Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352099747"},{"completedAt":"2026-08-20T07:41:05Z","conclusion":"success","databaseId":96352099950,"name":"Test (Python 3.13)","startedAt":"2026-08-20T07:40:44Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:45Z","status":"completed"},{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:47Z","conclusion":"success","name":"Setup Python","number":3,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:40:47Z","status":"completed"},{"completedAt":"2026-08-20T07:41:00Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Upload coverage to Codecov","number":6,"startedAt":"2026-08-20T07:41:00Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Post Setup Python","number":11,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:41:03Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352099950"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352100774,"name":"Changelog Fragment Check","startedAt":"2026-08-20T07:40:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352100774"},{"completedAt":"2026-08-20T07:41:33Z","conclusion":"success","databaseId":96352204774,"name":"Build Package","startedAt":"2026-08-20T07:41:12Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:41:14Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:41:13Z","status":"completed"},{"completedAt":"2026-08-20T07:41:15Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:41:14Z","status":"completed"},{"completedAt":"2026-08-20T07:41:15Z","conclusion":"success","name":"Setup Python","number":3,"startedAt":"2026-08-20T07:41:15Z","status":"completed"},{"completedAt":"2026-08-20T07:41:24Z","conclusion":"success","name":"Install build dependencies","number":4,"startedAt":"2026-08-20T07:41:15Z","status":"completed"},{"completedAt":"2026-08-20T07:41:29Z","conclusion":"success","name":"Build package","number":5,"startedAt":"2026-08-20T07:41:24Z","status":"completed"},{"completedAt":"2026-08-20T07:41:29Z","conclusion":"success","name":"Check package","number":6,"startedAt":"2026-08-20T07:41:29Z","status":"completed"},{"completedAt":"2026-08-20T07:41:30Z","conclusion":"success","name":"Upload artifacts","number":7,"startedAt":"2026-08-20T07:41:29Z","status":"completed"},{"completedAt":"2026-08-20T07:41:31Z","conclusion":"success","name":"Post Setup Python","number":13,"startedAt":"2026-08-20T07:41:30Z","status":"completed"},{"completedAt":"2026-08-20T07:41:31Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:41:31Z","status":"completed"},{"completedAt":"2026-08-20T07:41:31Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:41:31Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352204774"},{"completedAt":"2026-08-20T07:41:51Z","conclusion":"failure","databaseId":96352302584,"name":"Auto Release","startedAt":"2026-08-20T07:41:35Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:41:37Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:41:36Z","status":"completed"},{"completedAt":"2026-08-20T07:41:38Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:41:37Z","status":"completed"},{"completedAt":"2026-08-20T07:41:38Z","conclusion":"success","name":"Setup Python","number":3,"startedAt":"2026-08-20T07:41:38Z","status":"completed"},{"completedAt":"2026-08-20T07:41:43Z","conclusion":"success","name":"Install dependencies","number":4,"startedAt":"2026-08-20T07:41:38Z","status":"completed"},{"completedAt":"2026-08-20T07:41:43Z","conclusion":"success","name":"Check if version changed","number":5,"startedAt":"2026-08-20T07:41:43Z","status":"completed"},{"completedAt":"2026-08-20T07:41:44Z","conclusion":"success","name":"Download artifacts","number":6,"startedAt":"2026-08-20T07:41:43Z","status":"completed"},{"completedAt":"2026-08-20T07:41:49Z","conclusion":"failure","name":"Publish to PyPI","number":7,"startedAt":"2026-08-20T07:41:44Z","status":"completed"},{"completedAt":"2026-08-20T07:41:49Z","conclusion":"skipped","name":"Verify package on PyPI","number":8,"startedAt":"2026-08-20T07:41:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:49Z","conclusion":"skipped","name":"Create GitHub Release","number":9,"startedAt":"2026-08-20T07:41:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:49Z","conclusion":"success","name":"Post Publish to PyPI","number":16,"startedAt":"2026-08-20T07:41:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:49Z","conclusion":"skipped","name":"Post Setup Python","number":17,"startedAt":"2026-08-20T07:41:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:50Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":18,"startedAt":"2026-08-20T07:41:49Z","status":"completed"},{"completedAt":"2026-08-20T07:41:50Z","conclusion":"success","name":"Complete job","number":19,"startedAt":"2026-08-20T07:41:50Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352302584"},{"completedAt":"2026-08-20T07:41:34Z","conclusion":"skipped","databaseId":96352303749,"name":"Manual Release","startedAt":"2026-08-20T07:41:34Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106245/job/96352303749"}],"name":"Python CI/CD","status":"completed","updatedAt":"2026-08-20T07:41:52Z"} diff --git a/dev/log/issues/41/pulls/42/meta/run-rust-32345106299.json b/dev/log/issues/41/pulls/42/meta/run-rust-32345106299.json new file mode 100644 index 0000000..4b0f9ad --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/run-rust-32345106299.json @@ -0,0 +1 @@ +{"conclusion":"success","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106299,"headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","jobs":[{"completedAt":"2026-08-20T07:40:52Z","conclusion":"success","databaseId":96352099959,"name":"Detect Changes","startedAt":"2026-08-20T07:40:45Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:46Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:48Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:46Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Setup Node.js","number":3,"startedAt":"2026-08-20T07:40:48Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Detect changes","number":4,"startedAt":"2026-08-20T07:40:49Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Post Setup Node.js","number":7,"startedAt":"2026-08-20T07:40:49Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":8,"startedAt":"2026-08-20T07:40:49Z","status":"completed"},{"completedAt":"2026-08-20T07:40:49Z","conclusion":"success","name":"Complete job","number":9,"startedAt":"2026-08-20T07:40:49Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352099959"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352101055,"name":"Version Modification Check","startedAt":"2026-08-20T07:40:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352101055"},{"completedAt":"2026-08-20T07:40:42Z","conclusion":"skipped","databaseId":96352101432,"name":"Create Changelog PR","startedAt":"2026-08-20T07:40:43Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352101432"},{"completedAt":"2026-08-20T07:41:19Z","conclusion":"success","databaseId":96352136169,"name":"Lint and Format Check","startedAt":"2026-08-20T07:40:54Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:56Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:40:57Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:56Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Setup Node.js","number":4,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:00Z","conclusion":"success","name":"Cache cargo dependencies","number":5,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:03Z","conclusion":"success","name":"Check formatting","number":6,"startedAt":"2026-08-20T07:41:00Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Run clippy","number":7,"startedAt":"2026-08-20T07:41:03Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Check file size limit","number":8,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Run CI script tests","number":9,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:16Z","conclusion":"success","name":"Post Cache cargo dependencies","number":16,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:17Z","conclusion":"success","name":"Post Setup Node.js","number":17,"startedAt":"2026-08-20T07:41:16Z","status":"completed"},{"completedAt":"2026-08-20T07:41:17Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":18,"startedAt":"2026-08-20T07:41:17Z","status":"completed"},{"completedAt":"2026-08-20T07:41:17Z","conclusion":"success","name":"Complete job","number":19,"startedAt":"2026-08-20T07:41:17Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352136169"},{"completedAt":"2026-08-20T07:41:22Z","conclusion":"success","databaseId":96352136562,"name":"Test (Rust on macos-latest)","startedAt":"2026-08-20T07:40:55Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:57Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:40:58Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:41:00Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:40:58Z","status":"completed"},{"completedAt":"2026-08-20T07:41:01Z","conclusion":"success","name":"Cache cargo dependencies","number":4,"startedAt":"2026-08-20T07:41:00Z","status":"completed"},{"completedAt":"2026-08-20T07:41:14Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:41:01Z","status":"completed"},{"completedAt":"2026-08-20T07:41:15Z","conclusion":"success","name":"Run doc tests","number":6,"startedAt":"2026-08-20T07:41:14Z","status":"completed"},{"completedAt":"2026-08-20T07:41:15Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:41:15Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"success","name":"Post Cache cargo dependencies","number":13,"startedAt":"2026-08-20T07:41:15Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:41:18Z","status":"completed"},{"completedAt":"2026-08-20T07:41:18Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:41:18Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352136562"},{"completedAt":"2026-08-20T07:41:12Z","conclusion":"success","databaseId":96352136564,"name":"Test (Rust on ubuntu-latest)","startedAt":"2026-08-20T07:40:55Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:57Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:56Z","status":"completed"},{"completedAt":"2026-08-20T07:40:58Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:57Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:40:58Z","status":"completed"},{"completedAt":"2026-08-20T07:40:59Z","conclusion":"success","name":"Cache cargo dependencies","number":4,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:40:59Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Run doc tests","number":6,"startedAt":"2026-08-20T07:41:07Z","status":"completed"},{"completedAt":"2026-08-20T07:41:07Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:41:07Z","status":"completed"},{"completedAt":"2026-08-20T07:41:10Z","conclusion":"success","name":"Post Cache cargo dependencies","number":13,"startedAt":"2026-08-20T07:41:07Z","status":"completed"},{"completedAt":"2026-08-20T07:41:10Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:41:10Z","status":"completed"},{"completedAt":"2026-08-20T07:41:10Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:41:10Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352136564"},{"completedAt":"2026-08-20T07:42:02Z","conclusion":"success","databaseId":96352136597,"name":"Test (Rust on windows-latest)","startedAt":"2026-08-20T07:40:54Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:40:56Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:40:55Z","status":"completed"},{"completedAt":"2026-08-20T07:41:01Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:40:56Z","status":"completed"},{"completedAt":"2026-08-20T07:41:08Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:41:01Z","status":"completed"},{"completedAt":"2026-08-20T07:41:08Z","conclusion":"success","name":"Cache cargo dependencies","number":4,"startedAt":"2026-08-20T07:41:08Z","status":"completed"},{"completedAt":"2026-08-20T07:41:36Z","conclusion":"success","name":"Run tests","number":5,"startedAt":"2026-08-20T07:41:08Z","status":"completed"},{"completedAt":"2026-08-20T07:41:37Z","conclusion":"success","name":"Run doc tests","number":6,"startedAt":"2026-08-20T07:41:36Z","status":"completed"},{"completedAt":"2026-08-20T07:41:38Z","conclusion":"success","name":"Run example","number":7,"startedAt":"2026-08-20T07:41:37Z","status":"completed"},{"completedAt":"2026-08-20T07:41:58Z","conclusion":"success","name":"Post Cache cargo dependencies","number":13,"startedAt":"2026-08-20T07:41:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:00Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":14,"startedAt":"2026-08-20T07:41:58Z","status":"completed"},{"completedAt":"2026-08-20T07:42:00Z","conclusion":"success","name":"Complete job","number":15,"startedAt":"2026-08-20T07:42:00Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352136597"},{"completedAt":"2026-08-20T07:40:52Z","conclusion":"skipped","databaseId":96352137292,"name":"Changelog Fragment Check","startedAt":"2026-08-20T07:40:52Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352137292"},{"completedAt":"2026-08-20T07:42:32Z","conclusion":"success","databaseId":96352413465,"name":"Build Package","startedAt":"2026-08-20T07:42:04Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:42:06Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:42:05Z","status":"completed"},{"completedAt":"2026-08-20T07:42:08Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:42:06Z","status":"completed"},{"completedAt":"2026-08-20T07:42:09Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:42:08Z","status":"completed"},{"completedAt":"2026-08-20T07:42:09Z","conclusion":"success","name":"Cache cargo dependencies","number":4,"startedAt":"2026-08-20T07:42:09Z","status":"completed"},{"completedAt":"2026-08-20T07:42:27Z","conclusion":"success","name":"Build release","number":5,"startedAt":"2026-08-20T07:42:09Z","status":"completed"},{"completedAt":"2026-08-20T07:42:28Z","conclusion":"success","name":"Package crate","number":6,"startedAt":"2026-08-20T07:42:27Z","status":"completed"},{"completedAt":"2026-08-20T07:42:30Z","conclusion":"success","name":"Post Cache cargo dependencies","number":11,"startedAt":"2026-08-20T07:42:28Z","status":"completed"},{"completedAt":"2026-08-20T07:42:30Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":12,"startedAt":"2026-08-20T07:42:30Z","status":"completed"},{"completedAt":"2026-08-20T07:42:30Z","conclusion":"success","name":"Complete job","number":13,"startedAt":"2026-08-20T07:42:30Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352413465"},{"completedAt":"2026-08-20T07:43:02Z","conclusion":"success","databaseId":96352526159,"name":"Auto Release","startedAt":"2026-08-20T07:42:34Z","status":"completed","steps":[{"completedAt":"2026-08-20T07:42:35Z","conclusion":"success","name":"Set up job","number":1,"startedAt":"2026-08-20T07:42:35Z","status":"completed"},{"completedAt":"2026-08-20T07:42:37Z","conclusion":"success","name":"Run actions/checkout@v4","number":2,"startedAt":"2026-08-20T07:42:35Z","status":"completed"},{"completedAt":"2026-08-20T07:42:37Z","conclusion":"success","name":"Set up Rust","number":3,"startedAt":"2026-08-20T07:42:37Z","status":"completed"},{"completedAt":"2026-08-20T07:42:38Z","conclusion":"success","name":"Setup Node.js","number":4,"startedAt":"2026-08-20T07:42:37Z","status":"completed"},{"completedAt":"2026-08-20T07:42:38Z","conclusion":"success","name":"Configure git","number":5,"startedAt":"2026-08-20T07:42:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:38Z","conclusion":"success","name":"Determine bump type from changelog fragments","number":6,"startedAt":"2026-08-20T07:42:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:38Z","conclusion":"success","name":"Check if release is needed","number":7,"startedAt":"2026-08-20T07:42:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:44Z","conclusion":"success","name":"Collect changelog and bump version","number":8,"startedAt":"2026-08-20T07:42:38Z","status":"completed"},{"completedAt":"2026-08-20T07:42:44Z","conclusion":"success","name":"Get current version","number":9,"startedAt":"2026-08-20T07:42:44Z","status":"completed"},{"completedAt":"2026-08-20T07:42:52Z","conclusion":"success","name":"Build release","number":10,"startedAt":"2026-08-20T07:42:44Z","status":"completed"},{"completedAt":"2026-08-20T07:42:58Z","conclusion":"success","name":"Publish to crates.io","number":11,"startedAt":"2026-08-20T07:42:52Z","status":"completed"},{"completedAt":"2026-08-20T07:43:00Z","conclusion":"success","name":"Create GitHub Release","number":12,"startedAt":"2026-08-20T07:42:58Z","status":"completed"},{"completedAt":"2026-08-20T07:43:00Z","conclusion":"success","name":"Post Setup Node.js","number":23,"startedAt":"2026-08-20T07:43:00Z","status":"completed"},{"completedAt":"2026-08-20T07:43:01Z","conclusion":"success","name":"Post Run actions/checkout@v4","number":24,"startedAt":"2026-08-20T07:43:00Z","status":"completed"},{"completedAt":"2026-08-20T07:43:01Z","conclusion":"success","name":"Complete job","number":25,"startedAt":"2026-08-20T07:43:01Z","status":"completed"}],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352526159"},{"completedAt":"2026-08-20T07:42:32Z","conclusion":"skipped","databaseId":96352526996,"name":"Instant Release","startedAt":"2026-08-20T07:42:32Z","status":"completed","steps":[],"url":"https://github.com/link-foundation/lino-objects-codec/actions/runs/32345106299/job/96352526996"}],"name":"Rust CI/CD","status":"completed","updatedAt":"2026-08-20T07:43:03Z"} diff --git a/dev/log/issues/41/pulls/42/meta/runs.json b/dev/log/issues/41/pulls/42/meta/runs.json new file mode 100644 index 0000000..cbf3ef0 --- /dev/null +++ b/dev/log/issues/41/pulls/42/meta/runs.json @@ -0,0 +1 @@ +[{"conclusion":"success","createdAt":"2026-08-20T07:45:21Z","databaseId":32345476295,"displayTitle":"[WIP] Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all","event":"pull_request","headBranch":"issue-41-297a752a4939","headSha":"cd52cd7fbf54e8ebdf797ca1b1039eb3eae03be2","name":"Cross-Language Parity","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106299,"displayTitle":"Merge pull request #40 from link-foundation/issue-39-e53c893293ed","event":"push","headBranch":"main","headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106283,"displayTitle":"Merge pull request #40 from link-foundation/issue-39-e53c893293ed","event":"push","headBranch":"main","headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","name":"C# CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106245,"displayTitle":"Merge pull request #40 from link-foundation/issue-39-e53c893293ed","event":"push","headBranch":"main","headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:40:42Z","databaseId":32345106180,"displayTitle":"Merge pull request #40 from link-foundation/issue-39-e53c893293ed","event":"push","headBranch":"main","headSha":"41e3f4a8d83207d093d910f0a6d503af3e0d7fc8","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:36Z","databaseId":32344648939,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"Cross-Language Parity","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:01Z","databaseId":32344604794,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"Cross-Language Parity","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:00Z","databaseId":32344603645,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:00Z","databaseId":32344603638,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:00Z","databaseId":32344603492,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T07:34:00Z","databaseId":32344603461,"displayTitle":"Apply readable Links Notation format to all languages and docs","event":"pull_request","headBranch":"issue-39-e53c893293ed","headSha":"5d193739bfa89fd381b914e49bfbee5748775b36","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T05:47:14Z","databaseId":32336933162,"displayTitle":"Merge pull request #38 from link-foundation/issue-37-0e0bcabcea22","event":"push","headBranch":"main","headSha":"ea6d05f51396d3211ee54572f3d2e6cd26517246","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-08-20T05:42:07Z","databaseId":32336595605,"displayTitle":"feat(rust): make readable indented Links Notation the default encoding","event":"pull_request","headBranch":"issue-37-0e0bcabcea22","headSha":"29373e36e8aa7cf7122795894ce4cb817061ba01","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-10T19:42:06Z","databaseId":25637974944,"displayTitle":"Merge pull request #36 from link-foundation/issue-35-03946ff48852","event":"push","headBranch":"main","headSha":"0529f91279da52f6e6b475cb2169b04edbd431ea","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-10T19:38:16Z","databaseId":25637892451,"displayTitle":"feat(js): support readable recursive indented data","event":"pull_request","headBranch":"issue-35-03946ff48852","headSha":"a9ad369d371735f6f9194f905e8920044c9c62e4","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356119,"displayTitle":"Merge pull request #34 from link-foundation/issue-33-2c527aa50b81","event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356114,"displayTitle":"Merge pull request #34 from link-foundation/issue-33-2c527aa50b81","event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356076,"displayTitle":"Merge pull request #34 from link-foundation/issue-33-2c527aa50b81","event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"Python CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-05T20:04:13Z","databaseId":25399356073,"displayTitle":"Merge pull request #34 from link-foundation/issue-33-2c527aa50b81","event":"push","headBranch":"main","headSha":"83113482c6034c9cca20b0fa9ef1a99b1862a20d","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847432,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847426,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847417,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:54:00Z","databaseId":25398847415,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"6ff0eefab037c12736301a56d58f77e7d920f996","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507413,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507401,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507389,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:46:52Z","databaseId":25398507388,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"d0fe09b61aaa9728e8497f7ee06867ed2a24f7d8","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405283,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405261,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405242,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"C# CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-05T19:44:43Z","databaseId":25398405237,"displayTitle":"fix(ci): release-format consistency and npm publish verification","event":"pull_request","headBranch":"issue-33-2c527aa50b81","headSha":"ab1fb112915974330ad336e5db09e42b9bb43b7a","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053655,"displayTitle":"Merge pull request #32 from link-foundation/issue-31-bd2874f2d076","event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053645,"displayTitle":"Merge pull request #32 from link-foundation/issue-31-bd2874f2d076","event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T19:06:37Z","databaseId":25288053638,"displayTitle":"Merge pull request #32 from link-foundation/issue-31-bd2874f2d076","event":"push","headBranch":"main","headSha":"e1303e202fc96b20cae6adfef52ff132d36f7366","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384224,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384221,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:36:57Z","databaseId":25287384218,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"dc46eb61a843463864d8b7e4ab1178609b7afa12","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279804,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279793,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T18:32:00Z","databaseId":25287279785,"displayTitle":"fix(ci): harden release workflows","event":"pull_request","headBranch":"issue-31-bd2874f2d076","headSha":"c78eb96863b3a80c2925ea756f638c8a3c75f58a","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485840,"displayTitle":"Merge pull request #30 from link-foundation/issue-29-7f70f0d87db9","event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485829,"displayTitle":"Merge pull request #30 from link-foundation/issue-29-7f70f0d87db9","event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Python CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485826,"displayTitle":"Merge pull request #30 from link-foundation/issue-29-7f70f0d87db9","event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:55:55Z","databaseId":25286485825,"displayTitle":"Merge pull request #30 from link-foundation/issue-29-7f70f0d87db9","event":"push","headBranch":"main","headSha":"ddbf2556b5b0e91a05c053f321b85ce9eb02355a","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881566,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881563,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881551,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:28:41Z","databaseId":25285881549,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"d40bf6afe9ccee527f1241ccfdea544c3135d408","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710867,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710859,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710856,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T17:20:56Z","databaseId":25285710844,"displayTitle":"fix(ci): detect npm publish failures, split releases per language, surface credential runbooks","event":"pull_request","headBranch":"issue-29-7f70f0d87db9","headSha":"b2fc507cc806e486697eabaa7edbef1fbbbf3d04","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T13:38:17Z","databaseId":25280681547,"displayTitle":"Merge pull request #28 from link-foundation/issue-27-eb86cc75f92a","event":"push","headBranch":"main","headSha":"3806da8815dc66a01ed1d6b0a536a2f3035753c6","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T13:38:17Z","databaseId":25280681545,"displayTitle":"Merge pull request #28 from link-foundation/issue-27-eb86cc75f92a","event":"push","headBranch":"main","headSha":"3806da8815dc66a01ed1d6b0a536a2f3035753c6","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T13:10:38Z","databaseId":25280084202,"displayTitle":"docs(readme): document built-in references for cycles, drop legacy (ref X)","event":"pull_request","headBranch":"issue-27-eb86cc75f92a","headSha":"1c69cfbbc30cad5f68e640661c922384e0ec55c4","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T13:10:38Z","databaseId":25280084181,"displayTitle":"docs(readme): document built-in references for cycles, drop legacy (ref X)","event":"pull_request","headBranch":"issue-27-eb86cc75f92a","headSha":"1c69cfbbc30cad5f68e640661c922384e0ec55c4","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T12:40:43Z","databaseId":25279435356,"displayTitle":"Merge pull request #26 from link-foundation/issue-25-76b0e5ea19d6","event":"push","headBranch":"main","headSha":"1acc6f6f29f6cb775f021807a20cfe1e8f781ec1","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T12:40:43Z","databaseId":25279435257,"displayTitle":"Merge pull request #26 from link-foundation/issue-25-76b0e5ea19d6","event":"push","headBranch":"main","headSha":"1acc6f6f29f6cb775f021807a20cfe1e8f781ec1","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T12:40:43Z","databaseId":25279435252,"displayTitle":"Merge pull request #26 from link-foundation/issue-25-76b0e5ea19d6","event":"push","headBranch":"main","headSha":"1acc6f6f29f6cb775f021807a20cfe1e8f781ec1","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T12:40:43Z","databaseId":25279435247,"displayTitle":"Merge pull request #26 from link-foundation/issue-25-76b0e5ea19d6","event":"push","headBranch":"main","headSha":"1acc6f6f29f6cb775f021807a20cfe1e8f781ec1","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:31:48Z","databaseId":25277982314,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"240826f47ef56d52ff081e6a53c7e6bef4941bc6","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:31:48Z","databaseId":25277982313,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"240826f47ef56d52ff081e6a53c7e6bef4941bc6","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:31:48Z","databaseId":25277982309,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"240826f47ef56d52ff081e6a53c7e6bef4941bc6","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:31:48Z","databaseId":25277982307,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"240826f47ef56d52ff081e6a53c7e6bef4941bc6","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T11:30:18Z","databaseId":25277949752,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"d75c8e9765f0a46a76e82f9f04cc0e5cdb7c535b","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"cancelled","createdAt":"2026-05-03T11:30:18Z","databaseId":25277949744,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"d75c8e9765f0a46a76e82f9f04cc0e5cdb7c535b","name":"Rust CI/CD","status":"completed"},{"conclusion":"cancelled","createdAt":"2026-05-03T11:30:18Z","databaseId":25277949738,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"d75c8e9765f0a46a76e82f9f04cc0e5cdb7c535b","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:30:18Z","databaseId":25277949737,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"d75c8e9765f0a46a76e82f9f04cc0e5cdb7c535b","name":"Python CI/CD","status":"completed"},{"conclusion":"cancelled","createdAt":"2026-05-03T11:29:15Z","databaseId":25277925944,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"cf7453da754e01e9b909e6cb888da90261752081","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-05-03T11:29:15Z","databaseId":25277925937,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"cf7453da754e01e9b909e6cb888da90261752081","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-05-03T11:29:15Z","databaseId":25277925936,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"cf7453da754e01e9b909e6cb888da90261752081","name":"Python CI/CD","status":"completed"},{"conclusion":"cancelled","createdAt":"2026-05-03T11:29:15Z","databaseId":25277925934,"displayTitle":"Publish all language packages reliably and surface registry status as badges","event":"pull_request","headBranch":"issue-25-76b0e5ea19d6","headSha":"cf7453da754e01e9b909e6cb888da90261752081","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T20:29:40Z","databaseId":20830780347,"displayTitle":"Merge pull request #24 from link-foundation/issue-19-d88ab983ff7d","event":"push","headBranch":"main","headSha":"625e5371ad5d5cb6e20171973320e9db2d158833","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T20:04:14Z","databaseId":20830088983,"displayTitle":"feat(rust): support both CARGO_REGISTRY_TOKEN and CARGO_TOKEN for publishing","event":"pull_request","headBranch":"issue-19-d88ab983ff7d","headSha":"07555cefada174858b287a95bad32003a742e589","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T20:00:49Z","databaseId":20829988506,"displayTitle":"feat(rust): support both CARGO_REGISTRY_TOKEN and CARGO_TOKEN for publishing","event":"pull_request","headBranch":"issue-19-d88ab983ff7d","headSha":"0d8c934d7e67a6981b959667842ff91c523ac783","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:20:58Z","databaseId":20828901930,"displayTitle":"Merge pull request #23 from link-foundation/issue-22-e5ea3b44ef6b","event":"push","headBranch":"main","headSha":"f4fbe7b4243a59fdae5de784cfb9ce1fe336a625","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:20:58Z","databaseId":20828901908,"displayTitle":"Merge pull request #23 from link-foundation/issue-22-e5ea3b44ef6b","event":"push","headBranch":"main","headSha":"f4fbe7b4243a59fdae5de784cfb9ce1fe336a625","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:03:04Z","databaseId":20828413771,"displayTitle":"Fix yargs reserved word conflict for --version option","event":"pull_request","headBranch":"issue-22-e5ea3b44ef6b","headSha":"edd41f8b10da6ba1a21d9ea5f28c434d26b93212","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:03:04Z","databaseId":20828413731,"displayTitle":"Fix yargs reserved word conflict for --version option","event":"pull_request","headBranch":"issue-22-e5ea3b44ef6b","headSha":"edd41f8b10da6ba1a21d9ea5f28c434d26b93212","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:00:50Z","databaseId":20828349443,"displayTitle":"Fix yargs reserved word conflict for --version option","event":"pull_request","headBranch":"issue-22-e5ea3b44ef6b","headSha":"ac2ba3c259e77b52294b0223f6cdf59e019746da","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T19:00:50Z","databaseId":20828349419,"displayTitle":"Fix yargs reserved word conflict for --version option","event":"pull_request","headBranch":"issue-22-e5ea3b44ef6b","headSha":"ac2ba3c259e77b52294b0223f6cdf59e019746da","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:48:44Z","databaseId":20828012417,"displayTitle":"Merge pull request #21 from link-foundation/issue-20-d21ab54e8ff1","event":"push","headBranch":"main","headSha":"c79c77d68a90cadbd439c9613a7050501095c92d","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T18:48:44Z","databaseId":20828012412,"displayTitle":"Merge pull request #21 from link-foundation/issue-20-d21ab54e8ff1","event":"push","headBranch":"main","headSha":"c79c77d68a90cadbd439c9613a7050501095c92d","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:42:07Z","databaseId":20827831824,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"bb3ab1b83283b2a7495e8577aeef9b7168239aee","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:42:07Z","databaseId":20827831792,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"bb3ab1b83283b2a7495e8577aeef9b7168239aee","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:38:39Z","databaseId":20827734590,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"30041e7a24f6b2c64dcc297cdbd4a6f0a2af6d73","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:38:39Z","databaseId":20827734568,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"30041e7a24f6b2c64dcc297cdbd4a6f0a2af6d73","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T18:35:38Z","databaseId":20827650539,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"a46090ac52040c4fcae0b259f98309d7553b1d40","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T18:35:38Z","databaseId":20827650516,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"a46090ac52040c4fcae0b259f98309d7553b1d40","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T18:33:33Z","databaseId":20827593199,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"caddb8677c9995768ca83f1464c34bb9bba7a9de","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T18:33:33Z","databaseId":20827593167,"displayTitle":"Align CI/CD pipelines with template best practices","event":"pull_request","headBranch":"issue-20-d21ab54e8ff1","headSha":"caddb8677c9995768ca83f1464c34bb9bba7a9de","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T03:29:00Z","databaseId":20804477007,"displayTitle":"Merge pull request #18 from link-foundation/issue-17-ace5a094a724","event":"push","headBranch":"main","headSha":"5ecc9f59ff628f2b8af445f1e79b1faeed2cf9a3","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-08T03:29:00Z","databaseId":20804477003,"displayTitle":"Merge pull request #18 from link-foundation/issue-17-ace5a094a724","event":"push","headBranch":"main","headSha":"5ecc9f59ff628f2b8af445f1e79b1faeed2cf9a3","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T03:29:00Z","databaseId":20804477002,"displayTitle":"Merge pull request #18 from link-foundation/issue-17-ace5a094a724","event":"push","headBranch":"main","headSha":"5ecc9f59ff628f2b8af445f1e79b1faeed2cf9a3","name":"Rust CI/CD","status":"completed"},{"conclusion":"failure","createdAt":"2026-01-08T03:29:00Z","databaseId":20804476989,"displayTitle":"Merge pull request #18 from link-foundation/issue-17-ace5a094a724","event":"push","headBranch":"main","headSha":"5ecc9f59ff628f2b8af445f1e79b1faeed2cf9a3","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-04T00:51:25Z","databaseId":20685308075,"displayTitle":"Add indented Links Notation format support (#17)","event":"pull_request","headBranch":"issue-17-ace5a094a724","headSha":"1412114156ec31a9e95272c9bb3e91a393dc211a","name":"JavaScript CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-04T00:51:25Z","databaseId":20685308067,"displayTitle":"Add indented Links Notation format support (#17)","event":"pull_request","headBranch":"issue-17-ace5a094a724","headSha":"1412114156ec31a9e95272c9bb3e91a393dc211a","name":"C# CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-04T00:51:25Z","databaseId":20685308065,"displayTitle":"Add indented Links Notation format support (#17)","event":"pull_request","headBranch":"issue-17-ace5a094a724","headSha":"1412114156ec31a9e95272c9bb3e91a393dc211a","name":"Python CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-04T00:51:25Z","databaseId":20685308057,"displayTitle":"Add indented Links Notation format support (#17)","event":"pull_request","headBranch":"issue-17-ace5a094a724","headSha":"1412114156ec31a9e95272c9bb3e91a393dc211a","name":"Rust CI/CD","status":"completed"},{"conclusion":"success","createdAt":"2026-01-04T00:47:26Z","databaseId":20685265206,"displayTitle":"Add indented Links Notation format support (#17)","event":"pull_request","headBranch":"issue-17-ace5a094a724","headSha":"68a294b2a936ce0458493b30d65f090e2611b805","name":"Python CI/CD","status":"completed"}] diff --git a/dev/log/issues/41/pulls/42/templates/CI-CD-BEST-PRACTICES.md b/dev/log/issues/41/pulls/42/templates/CI-CD-BEST-PRACTICES.md new file mode 100644 index 0000000..d0bedd0 --- /dev/null +++ b/dev/log/issues/41/pulls/42/templates/CI-CD-BEST-PRACTICES.md @@ -0,0 +1,437 @@ +# CI/CD Best Practices for AI-Driven Development (languages: en • [zh](CI-CD-BEST-PRACTICES.zh.md) • [hi](CI-CD-BEST-PRACTICES.hi.md) • [ru](CI-CD-BEST-PRACTICES.ru.md)) + +This document describes CI/CD best practices that significantly improve the quality and reliability of AI-driven development workflows. When properly configured, Hive Mind AI solvers are forced to iterate with CI/CD checks until all tests pass, ensuring code quality meets the highest standards. + +## Why CI/CD Matters for AI Development + +Hive Mind's AI issue solver is instructed to pay attention to CI/CD checks in each pull request. This creates a powerful feedback loop: + +1. **AI creates a solution** - The solver generates code based on issue requirements +2. **CI/CD validates the solution** - Automated checks verify code quality +3. **AI iterates until passing** - The solver fixes issues until all checks pass +4. **Quality is guaranteed** - No code merges without passing all gates + +This approach ensures consistent quality regardless of whether the team consists of humans, AIs, or both. + +## Recommended CI/CD Templates + +We provide ready-to-use templates for multiple languages with all best practices pre-configured: + +| Language | Template Repository | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| JavaScript/TypeScript | [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) | +| Rust | [rust-ai-driven-development-pipeline-template](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template) | +| Python | [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) | +| Go | [go-ai-driven-development-pipeline-template](https://github.com/link-foundation/go-ai-driven-development-pipeline-template) | +| C# | [csharp-ai-driven-development-pipeline-template](https://github.com/link-foundation/csharp-ai-driven-development-pipeline-template) | +| Java | [java-ai-driven-development-pipeline-template](https://github.com/link-foundation/java-ai-driven-development-pipeline-template) | +| PHP | [php-ai-driven-development-pipeline-template](https://github.com/link-foundation/php-ai-driven-development-pipeline-template) | + +> **Tip:** You don't have to pick a template by hand. Run `fix --ci-cd` (see [Automatic CI/CD Remediation](#automatic-cicd-remediation)) and Hive Mind detects the repository's languages and selects the matching templates for you. + +## Key CI/CD Principles + +### 1. Run Checks Only on Relevant File Changes + +**Only trigger checks when relevant files change.** This dramatically reduces CI costs and run times. + +Use a `detect-changes` job at the start of your workflow to determine which file categories changed: + +```yaml +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + code-changed: ${{ steps.changes.outputs.code }} + docs-changed: ${{ steps.changes.outputs.docs }} + docker-changed: ${{ steps.changes.outputs.docker }} + workflow-changed: ${{ steps.changes.outputs.workflow }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Detect changes + id: changes + run: node scripts/detect-code-changes.mjs +``` + +Then gate each job on the relevant output: + +```yaml +test-suites: + needs: [detect-changes] + if: needs.detect-changes.outputs.code-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... + +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + # ... + +docker-pr-check: + needs: [detect-changes] + if: needs.detect-changes.outputs.docker-changed == 'true' || needs.detect-changes.outputs.workflow-changed == 'true' + # ... +``` + +**What to exclude from "code changes" detection:** + +- Markdown files (`*.md`) — documentation-only changes don't need changeset files +- `.changeset/` folder — changeset metadata isn't code +- `data/` and `experiments/` folders — non-production content +- `.gitkeep` files — placeholder files with no functional impact + +**What always triggers checks when changed:** + +- Source code files (`.mjs`, `.ts`, `.py`, `.rs`, `.go`, etc.) +- `package.json` / dependency manifests +- CI/CD workflow files (`.github/workflows/*.yml`) +- `Dockerfile` and related infrastructure files + +### 2. File Size Limits + +**Enforce a maximum of 1000-1500 lines per code file.** + +This constraint benefits both AI and human developers: + +- AI models can read and understand entire files within context windows +- Humans can navigate and comprehend files without cognitive overload +- Forces modular, well-organized code architecture + +Example enforcement in CI (bash): + +```bash +find src/ -name "*.mjs" -type f | while read -r file; do + line_count=$(wc -l < "$file") + if [ "$line_count" -gt 1500 ]; then + echo "ERROR: $file has $line_count lines (limit: 1500)" + echo "::error file=$file::File has $line_count lines (limit: 1500)" + exit 1 + fi +done +``` + +**Synchronize the file-size ESLint rule with the CI check** to catch violations locally before CI: + +```js +// eslint.config.mjs +{ + rules: { + 'max-lines': ['error', { max: 1500 }] + } +} +``` + +### 3. Automated Code Formatting + +Consistent formatting eliminates style debates and reduces diff noise: + +| Language | Tool | +| --------------------- | ----------------------------- | +| JavaScript/TypeScript | ESLint + Prettier | +| Rust | rustfmt | +| Python | Ruff | +| Go | gofmt | +| C# | dotnet format | +| Java | Spotless (Google Java Format) | +| PHP | PHP CS Fixer | + +All templates include pre-commit hooks that run formatters automatically before each commit. + +### 4. Static Analysis & Linting + +Catch bugs and enforce patterns before code reaches review: + +| Language | Tools | +| --------------------- | ----------------------------------- | +| JavaScript/TypeScript | ESLint with strict rules | +| Rust | Clippy (pedantic + nursery) | +| Python | Ruff + mypy | +| Go | go vet + staticcheck | +| C# | .NET analyzers (warnings as errors) | +| Java | SpotBugs (maximum effort) | +| PHP | PHPStan (max level) | + +### 5. Fast-Fail Job Ordering + +**Run fast checks before slow checks** to give the fastest possible feedback: + +``` +Fast checks (~7-30s each): Slow checks (~1-10 min each): +├── test-compilation ├── test-suites (unit tests) +├── lint (format + ESLint) ├── test-execution (integration) +└── check-file-line-limits ├── docker-pr-check + └── helm-pr-check +``` + +Gate slow checks on fast checks: + +```yaml +test-suites: + needs: [test-compilation, lint, check-file-line-limits] + if: | + always() && + !cancelled() && + !contains(needs.*.result, 'failure') && + needs.test-compilation.result == 'success' && + needs.lint.result == 'success' && + needs.check-file-line-limits.result == 'success' +``` + +### 6. Changeset-Based Versioning + +All templates use a changeset system that: + +- **Eliminates merge conflicts** - Each PR creates an independent changeset file +- **Automates version bumps** - Highest bump type wins when merging +- **Generates changelogs** - Release notes are compiled automatically +- **Supports semantic versioning** - patch/minor/major bumps are explicit + +| Language | Tool | +| --------------------- | ---------------------------- | +| JavaScript/TypeScript | @changesets/cli | +| Rust | changelog.d + custom scripts | +| Python | Scriv | +| PHP | changelog.d + custom scripts | +| Go, C#, Java | Custom changeset workflows | + +**Exempt docs-only PRs from changeset requirements:** + +```yaml +changeset-check: + needs: [detect-changes] + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' +``` + +Documentation-only changes (updating `.md` files) should not require a version bump. + +### 7. Validate the Actual Merge Result + +**CI must test what will actually be merged, not a stale PR snapshot.** + +When a PR is opened against a base branch that later receives new commits, the GitHub merge preview can become stale. Simulate a fresh merge before running checks: + +```yaml +- name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git fetch origin "$BASE_REF" + BEHIND_COUNT=$(git rev-list --count HEAD..origin/$BASE_REF) + if [ "$BEHIND_COUNT" -gt 0 ]; then + git merge origin/$BASE_REF --no-edit || \ + (echo "::error::Merge conflict! PR must be rebased before merging." && exit 1) + fi +``` + +This ensures lint, file-size, and other checks validate the final merged state. + +### 8. Pre-commit Hooks + +Local quality gates prevent broken commits from reaching CI: + +1. Format check and auto-fix +2. Lint and static analysis +3. Type checking (where applicable) +4. File size validation +5. Secrets detection + +This "shift left" approach catches issues immediately rather than waiting for CI. + +### 9. Release Automation + +Automated release workflows ensure: + +- **No manual version management** - Versions update automatically +- **OIDC trusted publishing** - No API tokens needed in CI (npm, PyPI, crates.io) +- **Validated releases only** - All checks must pass before publishing +- **Dual trigger modes** - Both automatic (on merge) and manual (workflow dispatch) + +**Prohibit manual version changes** in PRs — all version bumps should be managed by the CI release workflow: + +```yaml +version-check: + if: github.event_name == 'pull_request' + steps: + - name: Check for version changes in package.json + run: node scripts/check-version.mjs +``` + +### 10. Concurrency Control + +**Separate cancellable read-only checks from non-cancellable write jobs.** Configure concurrency at the job level when a workflow contains both kinds of work: + +```yaml +jobs: + lint: + # Include the job identity (and matrix values, when present) so unrelated + # checks remain parallel while a newer run replaces only the stale check. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true + # ... + + deploy: + needs: [lint] + if: ${{ !cancelled() && needs.lint.result == 'success' }} + # Every job that writes to main or an external deployment target uses this + # repository-wide group, even when the jobs live in different workflows. + concurrency: + group: main-writer-${{ github.repository }}-main + cancel-in-progress: false + # ... +``` + +- **Read-only jobs:** Cancel superseded checks on both pull requests and `main` to reduce runner load. Give each job a distinct suffix; include relevant matrix values so different matrix entries can still run in parallel. +- **Dependent writers:** Use `needs` and require successful prerequisites. A cancelled prerequisite must make its write job not start. +- **Active writers:** Give every release, deploy, tag, generated-content push, and other write job the same repository-scoped group with `cancel-in-progress: false`. An already started writer finishes while the next writer waits in the queue, including writers from another workflow file. +- **Workflow scope:** Do not put cancellable concurrency at workflow level when the workflow has write jobs. Cancelling the workflow would also interrupt a writer that has already started. + +By default, a concurrency group keeps at most one running and one pending job; a newer pending writer replaces the older pending writer. If every queued write must run, add `queue: max` to the writer's concurrency block (up to 100 jobs can wait). `queue: max` cannot be combined with `cancel-in-progress: true`, and execution order follows when jobs start waiting rather than workflow dispatch order, so write jobs should remain idempotent. See [GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) for the current queue limits and semantics. + +Use `!cancelled()` instead of `always()` in job conditions so cancellation propagates correctly through the job graph. A bare `always()` can keep downstream work running after cancellation. + +### 11. Secrets Detection + +Prevent accidental credential leaks in CI: + +- Include a secrets scan step using tools like `secretlint` or `truffleHog` +- Fail CI immediately if secrets are detected +- Never log environment variables or token values + +### 12. Documentation Validation + +**Validate documentation files in CI just like code:** + +- Check file size limits (e.g., max 2500 lines for docs) +- Verify required sections exist in key documents +- Check for broken links using tools like `lychee` + +```yaml +validate-docs: + needs: [detect-changes] + if: needs.detect-changes.outputs.docs-changed == 'true' + steps: + - run: node tests/docs-validation.mjs +``` + +### 13. Container Images: Native Runners per Architecture + +**Build each architecture on its own native runner.** GitHub provides free arm64 Linux runners for public repositories (`ubuntu-24.04-arm`). Emulating arm64 with QEMU on an x86 runner is much slower for compiled languages, and building two architectures inside one job makes them sequential instead of parallel. + +```yaml +build-image: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: docker/build-push-action@v7 + with: + platforms: ${{ matrix.platform }} + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + +merge-manifest: + needs: [build-image] + steps: + - run: docker buildx imagetools create -t $IMAGE:$VERSION $DIGESTS +``` + +- **No `setup-qemu-action`.** Its presence means an architecture is being emulated; use a native runner instead. +- **Publish images for every architecture your users run.** A single-architecture image silently excludes Apple Silicon, Graviton, and arm CI runners. +- **Always cache.** Set `cache-from: type=gha` and `cache-to: type=gha,mode=max` on every build step; otherwise every architecture rebuilds the full dependency tree for every release. +- **Never gate the release on the image push.** Publish the GitHub Release and language-registry package first, then attach images as they finish. Release notes contain no data derived from image bytes, so a slow or failed registry push must not hide an otherwise completed release. +- **Assert what you shipped.** Verify that the published manifest lists every intended platform and that each default-branch tag has a corresponding GitHub Release; a missing release is otherwise easy to overlook. + +Reference implementations: [`link-foundation/box`](https://github.com/link-foundation/box) and [`link-assistant/hive-mind`](https://github.com/link-assistant/hive-mind). + +## Quality Enforcement Strategy + +The templates implement a defense-in-depth approach: + +``` +Developer Machine → CI/CD Pipeline → Release +├── Pre-commit hooks ├── detect-changes ├── All checks pass +├── Local tests ├── version-check ├── Version bump +└── IDE integration ├── changeset-check ├── Changelog update + ├── test-compilation └── Publish package + ├── lint (format+ESLint) + ├── check-file-line-limits + ├── test-suites + ├── test-execution + ├── validate-docs + └── docker-pr-check +``` + +Each layer catches different issues, ensuring no problematic code reaches production. + +## Getting Started + +1. **Choose a template** from the table above matching your language +2. **Use it as a GitHub template** to create your new repository +3. **Configure secrets** if needed for publishing (OIDC preferred) +4. **Start developing** with all best practices pre-configured + +The AI solvers will automatically respect and iterate with all configured checks, producing higher quality output than repositories without CI/CD enforcement. + +## Automatic CI/CD Remediation + +For an existing repository, you don't need to apply these practices by hand. The `fix` command automates the whole flow: + +```bash +fix https://github.com/owner/repo --ci-cd +``` + +This command: + +1. **Detects the repository's languages** using the GitHub Linguist API (`GET /repos/{owner}/{repo}/languages`), ordered by the number of bytes per language. +2. **Selects the matching CI/CD templates** from the table above, sorted so the template for the most-used language comes first. +3. **Inspects the latest default-branch commit** and collects its CI/CD runs (falling back to the most recent runs on the default branch when the latest commit has none). +4. **Creates a remediation issue** that lists the failing runs, the detected languages, the recommended templates, and a link back to this document. The issue is created as a **Bug** (with a `bug` label) and its title and text are taken from the [standard remediation template](https://github.com/link-assistant/web-capture/issues/139). +5. **Hands the issue off to `/solve --development-log --deep-analysis --auto-merge`**, which iterates until the fixes are merged. Every option `fix` does not consume itself (for example `--tool`, `--model`, `--think`) is forwarded to `/solve`. + +### Why the issue is a Bug, and what it leaves out + +`--development-log` replaces the template's retired case-study-folder instruction and collects artifacts under `./dev/log/issues/{issue-id}/pulls/{pull-id}`. `/fix` never emits the retired paragraph, including with `--no-solve` or partial option sets. `--deep-analysis` supplies the timeline, root-cause, debug-output, and upstream-reporting guidance, so `fix` conditionally omits the matching paragraphs instead of delivering them twice. + +That omission is only lossless because `/solve` emits the root-cause wording **only for bug-typed issues** — which is why `fix` creates the issue as a Bug. Issue types are configured per organization and labels per repository, so if the target repository accepts neither, the issue is still created without them. + +The retired paragraph cannot be restored by an option combination; `--development-log` is the only supported collection workflow. The remaining conditional omissions are controlled by `--deep-analysis`. + +### Language → Template Mapping + +The command maps detected languages to templates as follows (JavaScript and TypeScript share a single template): + +| Detected Language(s) | Template | +| --------------------- | ---------------------------------------------------------------- | +| JavaScript/TypeScript | `link-foundation/js-ai-driven-development-pipeline-template` | +| Rust | `link-foundation/rust-ai-driven-development-pipeline-template` | +| Python | `link-foundation/python-ai-driven-development-pipeline-template` | +| Go | `link-foundation/go-ai-driven-development-pipeline-template` | +| C# | `link-foundation/csharp-ai-driven-development-pipeline-template` | +| Java | `link-foundation/java-ai-driven-development-pipeline-template` | +| PHP | `link-foundation/php-ai-driven-development-pipeline-template` | + +Languages without a dedicated template (for example Shell or Dockerfile) are listed in the issue for awareness, and the closest matching template is recommended. + +Use `--dry-run` to preview the issue without creating it, and `--no-solve` to create the issue without starting `/solve`: + +```bash +fix owner/repo --ci-cd --dry-run +fix owner/repo --ci-cd --no-solve +``` + +## References + +- [Code Architecture Principles](https://github.com/link-foundation/code-architecture-principles) +- [Contributing Guidelines](./CONTRIBUTING.md) +- [Best Practices](./BEST-PRACTICES.md) From d9132088c8ebb4d95cfc26a9b72cb8c9c9e31c01 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:56:01 +0000 Subject: [PATCH 03/29] docs(dev-log): deep analysis of CI/CD false positives, negatives, warnings and errors --- dev/log/issues/41/pulls/42/ANALYSIS.md | 369 +++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 dev/log/issues/41/pulls/42/ANALYSIS.md diff --git a/dev/log/issues/41/pulls/42/ANALYSIS.md b/dev/log/issues/41/pulls/42/ANALYSIS.md new file mode 100644 index 0000000..05e5750 --- /dev/null +++ b/dev/log/issues/41/pulls/42/ANALYSIS.md @@ -0,0 +1,369 @@ +# Deep analysis — issue #41 "Check for all false positives, false negatives, warnings and errors in CI/CD and fix them all" + +- Issue: +- Pull request: +- Evidence: `dev/log/issues/41/pulls/42/` (collected before any change was made) + +--- + +## 1. Timeline of events + +All timestamps are UTC, taken from the run/step JSON in `meta/` and the log lines in `ci-logs/`. + +| Time | Event | Evidence | +| --- | --- | --- | +| 2026-08-20T07:40:32Z | PR #40 (issue #39, cross-language parity gate) merged to `main` as `41e3f4a` | `meta/runs.json` | +| 07:40:42Z | Push of `41e3f4a` triggers the four language workflows | `meta/run-*.json` | +| 07:41:49Z | **Python** `Auto Release / Publish to PyPI` fails: `invalid-publisher: valid token, but no corresponding publisher` | `ci-logs/python-32345106245.log` | +| 07:42:59Z | **C#** `dotnet nuget push` succeeds — `Created https://www.nuget.org/api/v2/package/ 673ms` / `Your package was pushed.` | `ci-logs/csharp-32345106283.log` | +| 07:43:00Z–07:44:00Z | **C#** `Verify package on NuGet` polls the flat-container API 6× at 10 s intervals; every attempt returns `404`; the step calls `::error` and exits 1 | `ci-logs/csharp-32345106283.log` | +| 07:43:1xZ | **JavaScript** and **Rust** runs finish green — but with warnings (see §4) | `ci-logs/js-*.log`, `ci-logs/rust-*.log` | +| 07:44:40Z | Issue #41 filed | `meta/issue-41.json` | +| 07:45:21Z | Parity workflow on the PR branch succeeds | `ci-logs/parity-32345476295.log` | +| 07:47:27Z | Manual probe of `https://api.nuget.org/v3-flatcontainer/lino.objects.codec/index.json` → still `BlobNotFound` (~4½ min after push) | `analysis/registry-presence.txt` | +| 07:49:23Z | Same probe → **HTTP 200**, body `{"versions":["0.2.0"]}` (~6½ min after push) | `analysis/registry-presence.txt` | + +The last two rows are the proof: the package the C# job declared missing **was published successfully**. +The job was red because it stopped looking too early. + +--- + +## 2. Every requirement in the issue + +The issue body is short, so each sentence is a requirement. They are numbered here and +referenced by number for the rest of this document. + +| # | Requirement (verbatim intent) | Where it is addressed | +| --- | --- | --- | +| **R1** | Find and fix **all** false positives, false negatives, warnings and errors in CI/CD | §3 (errors / false negatives), §4 (warnings), §5 (false positives) | +| **R2** | Use all the best practices from the CI/CD templates; check the full file tree to compare every GitHub workflow and CI/CD script | §6 | +| **R3** | If the same issue exists in a template, report it upstream in the templates too | §7 | +| **R4** | Follow the CI/CD best practices in `link-assistant/hive-mind/docs/CI-CD-BEST-PRACTICES.md` | §6, cross-referenced by rule number | +| **R5** | Plan and execute everything in this single pull request until every requirement is fully addressed | §8 (execution plan), PR #42 | + +The four templates used as the yardstick for R2: + +- `link-foundation/js-ai-driven-development-pipeline-template` +- `link-foundation/python-ai-driven-development-pipeline-template` +- `link-foundation/rust-ai-driven-development-pipeline-template` +- `link-foundation/csharp-ai-driven-development-pipeline-template` + +--- + +## 3. Errors and false negatives (R1) + +### 3.1 C# — `Verify package on NuGet` is a **false negative** (confirmed) + +**Symptom.** `C# CI/CD / Auto Release` red, with: + +``` +::error title=NuGet verification failed::Lino.Objects.Codec@0.2.0 is not on NuGet after publish. +``` + +**What actually happened.** The push succeeded. Six polls over 60 s all returned 404. The +package became visible at ~6½ minutes. + +**Root cause.** `.github/workflows/csharp.yml` verifies with a hard-coded +`for i in 1 2 3 4 5 6; do … sleep 10; done` — a **60-second budget** — against +`https://api.nuget.org/v3-flatcontainer/{id}/{version}/{id}.nuspec`. NuGet's flat-container +(package base address) resource is a CDN-fronted static blob store that is populated by an +asynchronous indexing pipeline *after* the gallery accepts the upload. The push API returning +`201 Created` says nothing about flat-container visibility. NuGet's own gallery issue tracker +documents `BlobNotFound` responses for packages that are already listed on nuget.org +(NuGet/NuGetGallery#9105, #5887), and the **C# pipeline template in this very organisation** +encodes the expectation explicitly in `scripts/wait-for-nuget.mjs`: + +> "NuGet's flat-container API can take up to 15 minutes to reflect a newly pushed package (see issue #13)" + +So the repository is using a 60-second timeout for an operation the org's own template says +can take 15 minutes. **The verification budget is 15× too small.** This is not flakiness — +it is a deterministic failure whenever indexing takes longer than a minute, which is the +common case for a brand-new package version. + +**Second defect in the same file.** The `manual-release` job pushes to NuGet but has **no +verification step at all**. So the two release paths disagree: the automatic one is +over-strict (fails on a successful publish), the manual one is under-strict (would stay green +on a genuinely failed publish). That asymmetry is itself a latent false *positive* (§5.2). + +**Fix.** Port the template's `scripts/wait-for-nuget.mjs` (bounded ~15-minute poll with +exponential backoff, structured output, verbose mode) plus its unit test, call it from +**both** the auto-release and manual-release jobs, and distinguish "not indexed yet" from +"push failed" in the error text. + +### 3.2 Python — `Publish to PyPI` is a **real error** (external configuration) + +**Symptom.** + +``` +::error::Trusted publishing exchange failure: +invalid-publisher: valid token, but no corresponding publisher (Publisher with matching claims was not found) + * sub: repo:link-foundation/lino-objects-codec:ref:refs/heads/main + * workflow_ref: link-foundation/lino-objects-codec/.github/workflows/python.yml@refs/heads/main + * environment: MISSING +``` + +**Root cause.** `https://pypi.org/pypi/lino-objects-codec/json` returns **404** — the project +has never been published. For a project that does not yet exist, PyPI requires a +[**pending publisher**](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) +to be registered under the publishing account *before* the first upload; a normal +(project-scoped) trusted publisher cannot be created because there is no project to scope it +to. No pending publisher exists for `lino-objects-codec`, therefore the OIDC token PyPI +receives is valid and correctly signed but matches no publisher record, which is exactly the +`invalid-publisher` class of failure documented in +[PyPI's troubleshooting guide](https://docs.pypi.org/trusted-publishers/troubleshooting/). + +The `environment: MISSING` claim is a contributing risk rather than the cause here: because +the workflow declares no `environment:`, PyPI can only match a publisher configured *without* +an environment. If the pending publisher is later created *with* an environment name, this +same error returns. The workflow and the PyPI configuration must agree on all four claims +(owner, repository, workflow filename, environment). + +**This cannot be fixed from inside the repository** — it needs a one-time action in the PyPI +account settings. What *can* and must be fixed in code is the diagnosis: today the job dies +with a raw OIDC error and no indication that the operator has to go configure something. + +**Fix.** +1. A **preflight** step that probes PyPI for the project and, when absent, prints an explicit + runbook: "this project does not exist on PyPI; create a pending publisher with + owner=…, repository=…, workflow=python.yml, environment=(leave blank)". +2. A `PYPI_API_TOKEN` fallback so the first publish can be done with a token when trusted + publishing is not yet wired up, matching what the Python template does. +3. Turn the raw OIDC failure into an annotated, actionable error instead of a stack trace. + +### 3.3 Python — post-publish verification window is 25 seconds + +``` +for i in 1 2 3 4 5; do … sleep 5; done +``` + +Same class of defect as §3.1, one order of magnitude worse. PyPI's JSON API is fast but not +instantaneous, and the `pip install` smoke test the template runs needs the file to reach the +CDN. The Python template uses `DEFAULT_INSTALL_ATTEMPTS = 6` with +`DEFAULT_INSTALL_DELAY_SECONDS = 20.0` (120 s) for exactly this reason. Even though this step +did not fire in the collected run (the publish failed first), it is a false negative waiting +to happen and must be fixed in the same pass. + +### 3.4 Rust — no post-publish verification at all + +`rust.yml` runs `cargo publish` and tolerates `already (uploaded|exists)`, then stops. There +is no crates.io readback. JS, Python and C# all verify; Rust does not. This is the mirror +image of §3.1: instead of a false negative it is a blind spot (§5.2). + +--- + +## 4. Warnings (R1) + +Every warning below was extracted from the collected logs. All four language runs are affected. + +### 4.1 Node 20 deprecation — **all five workflows** + +``` +Node.js 20 actions are deprecated. Please update the following actions to use Node.js 24 +``` + +| Action | Pinned here | Current | +| --- | --- | --- | +| `actions/checkout` | v4 | v7 | +| `actions/setup-node` | v4 | v7 | +| `actions/setup-python` | v5 | v7 | +| `actions/setup-dotnet` | v4 | v6 | +| `actions/upload-artifact` | v4 | v7 | +| `actions/download-artifact` | v4 | v8 | +| `actions/cache` | v4 | v6 | +| `codecov/codecov-action` | v4 | v7 | + +GitHub announced the Node 20 runtime deprecation on 2025-09-19. These are warnings today and +hard failures once the runtime is removed — a scheduled outage of the entire release pipeline. +The templates are already on the v6/v7 generation. + +### 4.2 Codecov — token missing on a protected branch + +``` +['warning'] Branch `main` is protected but no token was provided +``` + +Appears in the Python and C# runs. Coverage uploads from protected branches are rejected +without `CODECOV_TOKEN`, so coverage silently stops being recorded — a **silent** false +positive (the job is green, the data never arrives). + +### 4.3 NuGet — package has no readme + +``` +warn : Readme missing. Go to https://aka.ms/nuget-include-readme +``` + +`csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj` has no ``. The +template csproj packs the repository README. + +### 4.4 setuptools — deprecated license declaration + missing licence file + +``` +SetuptoolsDeprecationWarning: `project.license` as a TOML table is deprecated + !! …will be removed in a future version of setuptools (by 2027-Feb-18) +SetuptoolsWarning: File '/…/python/LICENSE' cannot be found +``` + +`python/pyproject.toml` uses `license = {file = "LICENSE"}` and points at `python/LICENSE`, +which **does not exist** (the licence lives at the repository root). Two defects in one line: +a deprecated form and a broken path. PEP 639 replaces it with an SPDX string plus +`license-files`. + +### 4.5 ESLint — complexity warnings in the JavaScript codec + +``` +js/src/… 219:3 warning Method '_encodeValue' has a complexity of 23. Maximum allowed is 15 +js/src/… 344:3 warning Method '_decodeLink' has a complexity of 55. Maximum allowed is 15 +js/src/… 344:14 warning Method '_decodeLink' has too many statements (95). Maximum allowed is 60 +``` + +**Constraint.** `scripts/check-language-parity.mjs` (added by PR #40 for issue #39) fails the +build if any language's `src/` changes without all four changing. Refactoring `js/src/` alone +is therefore not possible; either the refactor is mirrored in Python, Rust and C#, or the PR +opts out with `[skip-parity]`. Since the refactor is cosmetic and cross-language behavioural +parity is the more valuable invariant, the right call is to leave the codec bodies alone in +this PR and record the debt, rather than risk four simultaneous hand-refactors of the most +delicate code in the repository inside a CI-hygiene PR. + +### 4.6 npm — unknown user config + +``` +npm warn Unknown user config "always-auth" +``` + +Emitted by `actions/setup-node` whenever `registry-url` is set; npm 9 removed `always-auth`. +Fixed by upgrading `setup-node` (§4.1). + +### 4.7 npm audit — 7 vulnerabilities + +``` +7 vulnerabilities (2 moderate, 5 high) +``` + +`ajv <6.14.0`, `brace-expansion <=1.1.17`, `flatted <=3.4.1`, `js-yaml`, `minimatch <=3.1.3`, +`picomatch <=2.3.1`, `yaml 2.0.0–2.8.2` — all transitive dev dependencies, all with +`fix available via npm audit fix`. Nothing in the pipeline fails or even reports on this +today, because there is no security workflow (§6.6). + +--- + +## 5. False positives (R1) + +A false positive here means **CI is green while something is actually wrong**. + +### 5.1 Coverage is not being uploaded + +§4.2 — green job, no data. The step should fail, or at minimum annotate, when the upload is +rejected. + +### 5.2 Publish steps that cannot fail + +- `manual-release` in `csharp.yml` pushes to NuGet and never verifies (§3.1). +- `rust.yml` publishes to crates.io and never verifies (§3.4). + +In both cases a partial or rejected publish leaves a green checkmark and a version number that +does not exist in the registry — the most damaging kind of false positive, because downstream +consumers only discover it at install time. + +### 5.3 No security or link checking at all + +The repository has no CodeQL, no dependency review, no `npm audit` gate, and no link checker, +while all four templates ship `security.yml` and `links.yml`. Every run is green partly +because nothing is being checked. §4.7's seven vulnerabilities are the concrete proof. + +### 5.4 `always()` instead of `!cancelled()` + +`js.yml` and `rust.yml` gate summary/aggregate jobs with `always()`. `always()` runs the job +even when the workflow was **cancelled**, which can turn a cancellation into a reported +success. `!cancelled()` is the correct guard (best practice #12). + +--- + +## 6. Best-practice gap analysis (R2, R4) + +Rule numbers refer to `templates/CI-CD-BEST-PRACTICES.md`. + +| Rule | Requirement | This repository | Gap | +| --- | --- | --- | --- | +| #1 | Least-privilege top-level `permissions` | only `parity.yml` has it | ✗ four workflows | +| #3 | Every job has `timeout-minutes` | only `parity.yml` has it | ✗ four workflows | +| #7 | Simulate a fresh merge with the base branch | absent | ✗ all | +| #10 | Writer jobs: repo-scoped concurrency group, `cancel-in-progress: false`, `queue: max` | workflow-level `cancel-in-progress: true` on workflows that contain release jobs | ✗ — a release can be cancelled mid-publish | +| #12 | `!cancelled()` rather than `always()` | `always()` in js/rust | ✗ | +| — | Post-publish registry verification, generous budget | 60 s (C#), 25 s (Python), none (Rust) | ✗ | +| — | Change detection job | present in js/rust, absent in python/csharp | ✗ | +| — | OS matrix for tests | js/rust/csharp use 3 OSes; Python is ubuntu-only, single version | ✗ | +| — | `security.yml` (CodeQL, dependency-review, audit) | absent | ✗ | +| — | `links.yml` (lychee) | absent | ✗ | +| — | Current action major versions | Node 20 generation | ✗ | + +`cancel-in-progress: true` at **workflow level** (rule #10) deserves emphasis: it applies to +the release job as much as to the test jobs. Two pushes to `main` in quick succession can +cancel a run that has already executed `dotnet nuget push` / `cargo publish` but not yet +recorded the outcome — producing exactly the "published but CI says otherwise" state this +issue is about. The fix is the template's split: readers cancel, writers queue. + +--- + +## 7. Existing components and upstream reports (R3) + +### Reusable components already available in the organisation + +Nothing needs to be invented; the templates already solve every problem found: + +| Problem | Existing component | +| --- | --- | +| NuGet indexing lag (§3.1) | `csharp-…-template/scripts/wait-for-nuget.mjs` + `.test.mjs` (15-minute bounded poll) | +| Post-publish smoke test (C#) | `csharp-…-template/scripts/smoke-test-nuget-package.mjs` + `.test.mjs` | +| Post-publish smoke test (Python) | `python-…-template/scripts/smoke_test_published_package.py` | +| Robust npm publish + verification | `js/scripts/publish-to-npm.mjs` — **already in this repository**, and the model the other three languages should follow | +| Security scanning | `js-…-template/.github/workflows/security.yml` (CodeQL `[javascript-typescript, actions]`, `dependency-review-action@v5` `fail-on-severity: high`, `npm audit --audit-level=high`) | +| Link checking | `js-…-template/.github/workflows/links.yml` (`lychee-action@v2`, `--exclude-path docs/case-studies`, plus `scripts/check-web-archive.mjs`) | + +Third-party options considered and rejected: `nick-fields/retry` (adds a dependency for a +loop we already have), `pypa/gh-action-pypi-publish`'s own retry (does not cover the +readback), `dotnet nuget verify` (verifies signatures, not registry presence). + +### Defects that also exist in the templates → upstream reports (R3) + +| Defect | Template(s) affected | Action | +| --- | --- | --- | +| Node 20 actions | verified current in the templates — **not** an upstream defect | none | +| No crates.io post-publish verification | Rust template has the same blind spot as §3.4 | report upstream | +| Publish verification budget hard-coded inline instead of using the shared script | Python template's PyPI readback is shorter than its own install smoke test | report upstream | + +--- + +## 8. Execution plan (R5) + +Ordered so that each step is independently useful and independently committable. + +1. **Evidence** — commit `dev/log/issues/41/pulls/42/` (done: `cde9620`). +2. **C# false negative** — add `scripts/wait-for-nuget.mjs` + unit test; call it from both + `auto-release` and `manual-release`; verbose mode off by default. +3. **Python real error** — PyPI preflight + runbook + `PYPI_API_TOKEN` fallback; widen the + readback window. +4. **Rust blind spot** — add a crates.io readback with the same budget discipline. +5. **Action versions** — bump every action to its current major across all five workflows. +6. **Codecov** — pass `CODECOV_TOKEN`, bump to v7. +7. **Manifest warnings** — `` for C#; PEP 639 licence for Python. +8. **`npm audit fix`** in `js/`. +9. **Workflow hardening** — top-level `permissions`, `timeout-minutes` on every job, + reader/writer concurrency split, `!cancelled()`, fresh-merge simulation, `detect-changes` + for Python and C#, Python OS/version matrix. +10. **New workflows** — `security.yml`, `links.yml`. +11. **Upstream reports** — file the issues listed in §7. +12. **Release trigger** — bump versions / add changesets so the fixed pipeline actually + publishes. + +### Verbose / debug mode (default off) + +Where the evidence was insufficient to see *why* a step failed, the fix adds tracing rather +than a guess: + +- `wait-for-nuget.mjs` — `--verbose` / `NUGET_WAIT_VERBOSE=1` prints every poll's URL, status, + elapsed time and response headers. +- Python publish preflight — `PYPI_PREFLIGHT_VERBOSE=1` prints the resolved OIDC claims and the + PyPI project probe result, so the next `invalid-publisher` failure names the mismatched claim. +- Rust readback — `CRATES_WAIT_VERBOSE=1`. + +All three default to off, so normal runs stay quiet. From ef20bf64e0602b23641a6703ab5d075e375f9f0d Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 07:59:18 +0000 Subject: [PATCH 04/29] ci: add bounded post-publish registry readback with verbose mode The C# release job for 0.2.0 failed with 'is not on NuGet after publish' while the push had in fact succeeded: the inline verification polled the flat-container API six times over sixty seconds, and NuGet made the package visible about six and a half minutes later. NuGet documents package validation and indexing as taking up to fifteen minutes, so the budget was fifteen times too small and the red run was a false negative. scripts/wait-for-registry.mjs replaces the hand-rolled curl loops with one readback that knows each registry's own indexing window, tells slow indexing apart from a failed publish in its error message, and has a verbose mode (off by default) that prints every probe. A live probe of all four registries caught a second false negative in the readback itself: crates.io enforces an API data access policy that answers 403 to clients without a descriptive User-Agent, so an anonymous check would report a published crate as missing. Probes now identify themselves. --- experiments/verify-registry-readback.sh | 21 ++ scripts/wait-for-registry.mjs | 349 ++++++++++++++++++++++++ scripts/wait-for-registry.test.mjs | 313 +++++++++++++++++++++ 3 files changed, 683 insertions(+) create mode 100755 experiments/verify-registry-readback.sh create mode 100644 scripts/wait-for-registry.mjs create mode 100644 scripts/wait-for-registry.test.mjs diff --git a/experiments/verify-registry-readback.sh b/experiments/verify-registry-readback.sh new file mode 100755 index 0000000..2197922 --- /dev/null +++ b/experiments/verify-registry-readback.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Issue #41: prove `scripts/wait-for-registry.mjs` reports the real state of +# every registry this repository publishes to. +# +# The C# release job for 0.2.0 went red with "is not on NuGet after publish" +# while the package had in fact been published; this script is the manual +# counter-check. Run it any time a release job disagrees with a registry. +set -u +cd "$(dirname "$0")/.." + +probe() { + echo "--- $1 $2@$3 ---" + node scripts/wait-for-registry.mjs --registry "$1" --name "$2" --version "$3" \ + --max-attempts 1 --delay-seconds 1 --verbose + echo "exit: $?" +} + +probe nuget Lino.Objects.Codec "${NUGET_VERSION:-0.2.0}" +probe pypi lino-objects-codec "${PYPI_VERSION:-0.2.0}" +probe crates lino-objects-codec "${CRATES_VERSION:-0.4.0}" +probe npm lino-objects-codec "${NPM_VERSION:-0.5.0}" diff --git a/scripts/wait-for-registry.mjs b/scripts/wait-for-registry.mjs new file mode 100644 index 0000000..2ab1e2d --- /dev/null +++ b/scripts/wait-for-registry.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node + +/** + * Wait for a freshly published package version to become visible in its registry. + * + * Issue #41: the C# release job reported + * `Lino.Objects.Codec@0.2.0 is not on NuGet after publish` even though + * `dotnet nuget push` had already answered `201 Created`. The verification step + * polled NuGet's flat-container API six times at ten-second intervals — a + * sixty-second budget — while the package only became visible about six and a + * half minutes after the push. The job was red, the publish was fine: a false + * negative caused purely by an impatient readback. + * + * Every registry here indexes asynchronously, so `publish returned success` and + * `the package can be downloaded` are different events, sometimes minutes apart: + * + * - NuGet documents package validation and indexing as taking up to fifteen + * minutes (https://learn.microsoft.com/nuget/nuget-org/publish-a-package), + * and its flat container answers `BlobNotFound` until indexing completes. + * - PyPI, npm and crates.io are usually quick but still CDN-fronted, so a + * readback within a few seconds of the upload can miss. + * + * This script therefore replaces the hand-rolled `for i in 1 2 3; do curl; done` + * loops in the workflows with one bounded, per-registry-tuned poll that treats + * "not visible yet" as "keep waiting" and only fails once the registry's own + * documented indexing window has elapsed. + * + * Usage: + * node scripts/wait-for-registry.mjs --registry nuget --name Lino.Objects.Codec --version 0.2.0 + * + * Options: + * --registry Which registry to poll (required). + * --name Package name as the registry spells it (required). + * --version Version that was just published (required). + * --max-attempts Overrides the registry default. + * --delay-seconds Overrides the registry default. + * --verbose Print every probe's URL, status and elapsed time. + * + * Environment overrides, used by the tests and available for debugging a live + * run without editing the workflow: + * REGISTRY_WAIT_BASE_URL, REGISTRY_WAIT_MAX_ATTEMPTS, + * REGISTRY_WAIT_DELAY_SECONDS, REGISTRY_WAIT_VERBOSE + * + * Verbose output is off by default so a healthy run stays quiet; switch it on + * with `--verbose` or `REGISTRY_WAIT_VERBOSE=1` when a failure needs diagnosing. + * + * Writes `available=true|false` and `attempts=` to `$GITHUB_OUTPUT`. + * Exit code 0 means the version is visible, 1 means it is not (or misuse). + */ + +import { appendFileSync } from "node:fs"; + +/** + * Identify the caller on every probe. + * + * crates.io enforces a data access policy (https://crates.io/data-access) that + * answers `403` with an "unable to process your request" body to any client + * that does not send a descriptive `User-Agent`. Node's default `fetch` agent + * is rejected by it, so an anonymous readback would report a published crate as + * missing — the same false negative this script exists to prevent, just moved + * to a different registry. The other three registries ignore the header. + */ +export const USER_AGENT = + "lino-objects-codec-ci (+https://github.com/link-foundation/lino-objects-codec)"; + +/** + * Per-registry probe URLs and polling budgets. + * + * The budgets are deliberately asymmetric: they mirror what each registry + * documents about its own indexing latency rather than a single shared guess. + * NuGet gets fifteen minutes because that is the published upper bound; the + * others get five, which is already an order of magnitude more than their + * observed latency. + */ +export const REGISTRIES = { + nuget: { + label: "NuGet", + baseUrl: "https://api.nuget.org/v3-flatcontainer", + // 11 attempts with 90s between them = 15 minutes of waiting, matching + // NuGet's documented indexing window. + maxAttempts: 11, + delaySeconds: 90, + probeUrl: (baseUrl, name, version) => { + const id = name.toLowerCase(); + return `${baseUrl}/${id}/${version.toLowerCase()}/${id}.nuspec`; + }, + }, + pypi: { + label: "PyPI", + baseUrl: "https://pypi.org", + // 11 attempts with 30s between them = 5 minutes of waiting. + maxAttempts: 11, + delaySeconds: 30, + probeUrl: (baseUrl, name, version) => + `${baseUrl}/pypi/${name}/${version}/json`, + }, + crates: { + label: "crates.io", + baseUrl: "https://crates.io/api/v1", + maxAttempts: 11, + delaySeconds: 30, + probeUrl: (baseUrl, name, version) => + `${baseUrl}/crates/${name}/${version}`, + }, + npm: { + label: "npm", + baseUrl: "https://registry.npmjs.org", + maxAttempts: 11, + delaySeconds: 30, + probeUrl: (baseUrl, name, version) => `${baseUrl}/${name}/${version}`, + }, +}; + +/** + * Parse `--flag value` and `--flag=value` pairs plus the boolean `--verbose`. + * + * @param {string[]} argv + * @returns {Record} + */ +export function readCliOptions(argv) { + const options = {}; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + + const equals = arg.indexOf("="); + if (equals !== -1) { + options[arg.slice(2, equals)] = arg.slice(equals + 1); + continue; + } + + const name = arg.slice(2); + const next = argv[index + 1]; + if (name === "verbose" && (next === undefined || next.startsWith("--"))) { + options.verbose = true; + continue; + } + if (next === undefined || next.startsWith("--")) { + throw new Error(`Missing value for --${name}`); + } + options[name] = next; + index++; + } + return options; +} + +function parsePositiveInteger(value, optionName) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${optionName} must be a positive integer, got "${value}"`); + } + return parsed; +} + +function parseBoolean(value) { + if (value === true) return true; + if (value === undefined || value === null || value === "") return false; + return !["0", "false", "no", "off"].includes(String(value).toLowerCase()); +} + +/** + * Turn command line arguments and the environment into a validated config. + * + * Precedence is command line, then environment, then the registry's default, + * so a workflow can pin a budget and an operator can still override it for one + * re-run without touching the YAML. + * + * @param {string[]} argv + * @param {Record} env + */ +export function parseArgs(argv, env = process.env) { + const options = readCliOptions(argv); + const registryId = String(options.registry || env.REGISTRY_WAIT_REGISTRY || ""); + const registry = REGISTRIES[registryId]; + if (!registry) { + throw new Error( + `--registry must be one of ${Object.keys(REGISTRIES).join(", ")}, got "${registryId}"`, + ); + } + + return { + registryId, + registry, + baseUrl: + options["base-url"] || env.REGISTRY_WAIT_BASE_URL || registry.baseUrl, + name: String(options.name || env.REGISTRY_WAIT_NAME || ""), + version: String(options.version || env.REGISTRY_WAIT_VERSION || ""), + maxAttempts: parsePositiveInteger( + options["max-attempts"] || + env.REGISTRY_WAIT_MAX_ATTEMPTS || + String(registry.maxAttempts), + "--max-attempts", + ), + delaySeconds: parsePositiveInteger( + options["delay-seconds"] || + env.REGISTRY_WAIT_DELAY_SECONDS || + String(registry.delaySeconds), + "--delay-seconds", + ), + verbose: parseBoolean(options.verbose) || parseBoolean(env.REGISTRY_WAIT_VERBOSE), + }; +} + +/** + * Build the URL that answers "does this exact version exist?". + * + * @param {{registryId: string, baseUrl?: string, name: string, version: string}} params + * @returns {string} + */ +export function buildProbeUrl({ registryId, baseUrl, name, version }) { + const registry = REGISTRIES[registryId]; + if (!registry) throw new Error(`Unknown registry "${registryId}"`); + const root = (baseUrl || registry.baseUrl).replace(/\/+$/, ""); + return registry.probeUrl(root, name, version); +} + +/** + * Probe the registry once. + * + * A network error is reported as a non-fatal miss rather than thrown: a DNS + * blip in the middle of a fifteen-minute wait should cost one attempt, not the + * whole release. + * + * @returns {Promise<{available: boolean, status: number|string, url: string, error?: string}>} + */ +export async function probeOnce({ + registryId, + baseUrl, + name, + version, + fetchImpl = fetch, +}) { + const url = buildProbeUrl({ registryId, baseUrl, name, version }); + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { "User-Agent": USER_AGENT, Accept: "*/*" }, + }); + return { available: response.status === 200, status: response.status, url }; + } catch (error) { + return { + available: false, + status: "network-error", + url, + error: error.message, + }; + } +} + +function sleep(seconds) { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, seconds * 1000); + }); +} + +/** + * Poll until the version shows up or the budget runs out. + * + * @returns {Promise<{available: boolean, attempts: number}>} + */ +export async function waitForPackage({ + registryId, + baseUrl, + name, + version, + maxAttempts = REGISTRIES[registryId]?.maxAttempts ?? 10, + delaySeconds = REGISTRIES[registryId]?.delaySeconds ?? 30, + verbose = false, + probe = probeOnce, + sleepFn = sleep, + log = console.log, +}) { + const label = REGISTRIES[registryId]?.label ?? registryId; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const result = await probe({ registryId, baseUrl, name, version }); + log( + `${label}: ${name}@${version} -> ${result.status} (attempt ${attempt}/${maxAttempts})`, + ); + if (verbose) { + log(` probe url: ${result.url}`); + if (result.error) log(` probe error: ${result.error}`); + log(` elapsed: ~${(attempt - 1) * delaySeconds}s of ${(maxAttempts - 1) * delaySeconds}s budget`); + } + if (result.available) return { available: true, attempts: attempt }; + + if (attempt < maxAttempts) { + log(`${label}: not indexed yet, waiting ${delaySeconds}s`); + await sleepFn(delaySeconds); + } + } + return { available: false, attempts: maxAttempts }; +} + +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) appendFileSync(outputFile, `${name}=${value}\n`); +} + +export async function main({ + argv = process.argv.slice(2), + env = process.env, + log = console.log, + logError = console.error, +} = {}) { + let config; + try { + config = parseArgs(argv, env); + } catch (error) { + logError(`Error: ${error.message}`); + return 1; + } + + if (!config.name || !config.version) { + logError("Error: --name and --version are required"); + return 1; + } + + const budget = (config.maxAttempts - 1) * config.delaySeconds; + log( + `Waiting for ${config.name}@${config.version} on ${config.registry.label} ` + + `(up to ${config.maxAttempts} attempts over ~${Math.round(budget / 60)} minutes)`, + ); + + const { available, attempts } = await waitForPackage({ ...config, log }); + setOutput("available", available ? "true" : "false"); + setOutput("attempts", String(attempts)); + + if (!available) { + // Say plainly which of the two possibilities this is, because they need + // different responses: a publish that never happened must be re-run, while + // a publish that is merely slow to index must be left alone. + logError( + `::error title=${config.registry.label} verification failed::` + + `${config.name}@${config.version} was not visible on ${config.registry.label} ` + + `after ~${Math.round(budget / 60)} minutes. If the publish step reported success, ` + + `the version is probably still indexing — check ${buildProbeUrl(config)} before re-publishing. ` + + `Re-run with REGISTRY_WAIT_VERBOSE=1 for per-attempt detail.`, + ); + return 1; + } + + log(`${config.name}@${config.version} is available on ${config.registry.label}.`); + return 0; +} + +const invokedPath = process.argv[1] ? process.argv[1].replace(/\\/g, "/") : ""; +if (invokedPath.endsWith("wait-for-registry.mjs")) { + process.exitCode = await main(); +} diff --git a/scripts/wait-for-registry.test.mjs b/scripts/wait-for-registry.test.mjs new file mode 100644 index 0000000..906da88 --- /dev/null +++ b/scripts/wait-for-registry.test.mjs @@ -0,0 +1,313 @@ +#!/usr/bin/env node + +/** Tests for the post-publish registry readback (issue #41). */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + REGISTRIES, + buildProbeUrl, + main, + parseArgs, + probeOnce, + readCliOptions, + USER_AGENT, + waitForPackage, +} from "./wait-for-registry.mjs"; + +/** Collect log lines instead of printing them. */ +function recorder() { + const lines = []; + return { lines, log: (line) => lines.push(String(line)) }; +} + +/** A probe that answers with the given statuses in order, repeating the last. */ +function scriptedProbe(statuses) { + const calls = []; + const probe = async ({ registryId, baseUrl, name, version }) => { + const status = statuses[Math.min(calls.length, statuses.length - 1)]; + const url = buildProbeUrl({ registryId, baseUrl, name, version }); + calls.push(url); + return { available: status === 200, status, url }; + }; + return { calls, probe }; +} + +test("issue #41 regression: a package that indexes after the old 60s budget still passes", async () => { + // The C# run polled six times over sixty seconds and gave up; NuGet made the + // package visible about six and a half minutes after the push. With ninety + // second intervals that is the fifth attempt, well inside the new budget. + const { calls, probe } = scriptedProbe([404, 404, 404, 404, 200]); + const slept = []; + const { available, attempts } = await waitForPackage({ + registryId: "nuget", + name: "Lino.Objects.Codec", + version: "0.2.0", + probe, + sleepFn: async (seconds) => slept.push(seconds), + log: () => {}, + }); + + assert.equal(available, true); + assert.equal(attempts, 5); + assert.equal(calls.length, 5); + assert.deepEqual(slept, [90, 90, 90, 90]); +}); + +test("the NuGet budget covers the documented fifteen minute indexing window", () => { + const { maxAttempts, delaySeconds } = REGISTRIES.nuget; + assert.ok( + (maxAttempts - 1) * delaySeconds >= 15 * 60, + "NuGet readback must wait at least fifteen minutes", + ); +}); + +test("every registry waits at least five minutes", () => { + for (const [id, registry] of Object.entries(REGISTRIES)) { + const budget = (registry.maxAttempts - 1) * registry.delaySeconds; + assert.ok(budget >= 5 * 60, `${id} budget is only ${budget}s`); + } +}); + +test("a version that never appears fails after exhausting the budget", async () => { + const { probe } = scriptedProbe([404]); + const { available, attempts } = await waitForPackage({ + registryId: "crates", + name: "lino-objects-codec", + version: "9.9.9", + maxAttempts: 3, + delaySeconds: 1, + probe, + sleepFn: async () => {}, + log: () => {}, + }); + assert.equal(available, false); + assert.equal(attempts, 3); +}); + +test("a network error costs one attempt instead of aborting the wait", async () => { + let call = 0; + const probe = async () => { + call++; + if (call === 1) { + return { + available: false, + status: "network-error", + url: "https://example.invalid", + error: "getaddrinfo ENOTFOUND", + }; + } + return { available: true, status: 200, url: "https://example.invalid" }; + }; + const { available, attempts } = await waitForPackage({ + registryId: "npm", + name: "lino-objects-codec", + version: "0.5.0", + probe, + sleepFn: async () => {}, + log: () => {}, + }); + assert.equal(available, true); + assert.equal(attempts, 2); +}); + +test("probe URLs match each registry's version endpoint", () => { + assert.equal( + buildProbeUrl({ + registryId: "nuget", + name: "Lino.Objects.Codec", + version: "0.2.0", + }), + "https://api.nuget.org/v3-flatcontainer/lino.objects.codec/0.2.0/lino.objects.codec.nuspec", + ); + assert.equal( + buildProbeUrl({ + registryId: "pypi", + name: "lino-objects-codec", + version: "0.2.0", + }), + "https://pypi.org/pypi/lino-objects-codec/0.2.0/json", + ); + assert.equal( + buildProbeUrl({ + registryId: "crates", + name: "lino-objects-codec", + version: "0.4.0", + }), + "https://crates.io/api/v1/crates/lino-objects-codec/0.4.0", + ); + assert.equal( + buildProbeUrl({ + registryId: "npm", + name: "lino-objects-codec", + version: "0.5.0", + }), + "https://registry.npmjs.org/lino-objects-codec/0.5.0", + ); +}); + +test("a trailing slash on the base URL does not double up", () => { + assert.equal( + buildProbeUrl({ + registryId: "npm", + baseUrl: "https://registry.npmjs.org/", + name: "x", + version: "1.0.0", + }), + "https://registry.npmjs.org/x/1.0.0", + ); +}); + +test("command line options beat environment variables, which beat defaults", () => { + const fromCli = parseArgs( + ["--registry", "nuget", "--name", "A", "--version", "1.0.0", "--max-attempts", "3"], + { REGISTRY_WAIT_MAX_ATTEMPTS: "7" }, + ); + assert.equal(fromCli.maxAttempts, 3); + + const fromEnv = parseArgs(["--registry", "nuget", "--name", "A", "--version", "1.0.0"], { + REGISTRY_WAIT_MAX_ATTEMPTS: "7", + }); + assert.equal(fromEnv.maxAttempts, 7); + + const fromDefault = parseArgs( + ["--registry", "nuget", "--name", "A", "--version", "1.0.0"], + {}, + ); + assert.equal(fromDefault.maxAttempts, REGISTRIES.nuget.maxAttempts); +}); + +test("--flag=value is accepted as well as --flag value", () => { + assert.deepEqual(readCliOptions(["--registry=npm", "--name", "a"]), { + registry: "npm", + name: "a", + }); +}); + +test("verbose is off by default and can be switched on either way", () => { + const base = ["--registry", "npm", "--name", "a", "--version", "1.0.0"]; + assert.equal(parseArgs(base, {}).verbose, false); + assert.equal(parseArgs([...base, "--verbose"], {}).verbose, true); + assert.equal(parseArgs(base, { REGISTRY_WAIT_VERBOSE: "1" }).verbose, true); + assert.equal(parseArgs(base, { REGISTRY_WAIT_VERBOSE: "0" }).verbose, false); +}); + +test("verbose mode prints the probe URL and the elapsed budget", async () => { + const { lines, log } = recorder(); + const { probe } = scriptedProbe([200]); + await waitForPackage({ + registryId: "npm", + name: "a", + version: "1.0.0", + verbose: true, + probe, + sleepFn: async () => {}, + log, + }); + assert.ok(lines.some((line) => line.includes("probe url:"))); + assert.ok(lines.some((line) => line.includes("budget"))); +}); + +test("quiet mode prints one line per attempt and no probe detail", async () => { + const { lines, log } = recorder(); + const { probe } = scriptedProbe([200]); + await waitForPackage({ + registryId: "npm", + name: "a", + version: "1.0.0", + probe, + sleepFn: async () => {}, + log, + }); + assert.equal(lines.length, 1); + assert.ok(!lines[0].includes("probe url:")); +}); + +test("an unknown registry is rejected", () => { + assert.throws( + () => parseArgs(["--registry", "maven", "--name", "a", "--version", "1"], {}), + /--registry must be one of/, + ); +}); + +test("a non-positive budget is rejected", () => { + assert.throws( + () => + parseArgs( + ["--registry", "npm", "--name", "a", "--version", "1", "--max-attempts", "0"], + {}, + ), + /positive integer/, + ); +}); + +test("main requires a name and a version", async () => { + const { lines, log } = recorder(); + const code = await main({ + argv: ["--registry", "npm"], + env: {}, + log, + logError: log, + }); + assert.equal(code, 1); + assert.ok(lines.some((line) => line.includes("--name and --version are required"))); +}); + +test("main's failure message distinguishes slow indexing from a failed publish", async () => { + const { lines, log } = recorder(); + const code = await main({ + argv: [ + "--registry", + "npm", + "--name", + "lino-objects-codec", + "--version", + "9.9.9", + "--max-attempts", + "1", + "--delay-seconds", + "1", + "--base-url", + "https://registry.invalid", + ], + env: {}, + log, + logError: log, + }); + assert.equal(code, 1); + const message = lines.join("\n"); + assert.ok(message.includes("still indexing")); + assert.ok(message.includes("REGISTRY_WAIT_VERBOSE=1")); +}); + +test("probeOnce reports a network failure without throwing", async () => { + const result = await probeOnce({ + registryId: "npm", + name: "a", + version: "1.0.0", + fetchImpl: async () => { + throw new Error("boom"); + }, + }); + assert.equal(result.available, false); + assert.equal(result.status, "network-error"); + assert.equal(result.error, "boom"); +}); + +test("probes identify themselves, because crates.io rejects anonymous clients", async () => { + // crates.io answers 403 with "in violation of our API data access policy" to + // requests without a descriptive User-Agent, which would report a published + // crate as missing. Verified live against 0.4.0: 403 without, 200 with. + let seen; + await probeOnce({ + registryId: "crates", + name: "lino-objects-codec", + version: "0.4.0", + fetchImpl: async (url, init) => { + seen = init; + return { status: 200 }; + }, + }); + assert.equal(seen.headers["User-Agent"], USER_AGENT); + assert.match(USER_AGENT, /lino-objects-codec/); +}); From e46dd0ba84c7942e7619d88c77be4cee5a2b0432 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:00:23 +0000 Subject: [PATCH 05/29] ci(csharp): use the bounded readback in both release paths Auto-release stops failing on a publish that is merely still indexing, and manual-release gains the verification it never had. --- .github/workflows/csharp.yml | 51 ++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 1030b82..844690a 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -277,11 +277,18 @@ jobs: echo "$OUT" - name: Verify package on NuGet - # Mirrors the PyPI verification step (see python.yml). The NuGet CDN can - # take a minute or so to mirror a successful push; without an explicit - # registry probe the workflow can succeed even if the package never - # surfaces. See docs/case-studies/issue-33/README.md. + # Issue #41: this step used to poll the flat-container API six times at + # ten-second intervals. NuGet documents package validation and indexing + # as taking up to fifteen minutes, so a sixty-second budget reported a + # successful publish as a failure — run 32345106283 went red while + # 0.2.0 was already on its way to the CDN. The readback now waits for + # the window NuGet actually documents. See + # dev/log/issues/41/pulls/42/ANALYSIS.md and + # docs/case-studies/issue-33/README.md. if: steps.version_check.outputs.should_release == 'true' && steps.nuget_publish.outcome == 'success' + env: + # Switch to '1' when a readback needs diagnosing; quiet by default. + REGISTRY_WAIT_VERBOSE: '0' run: | PKG="${{ steps.version_check.outputs.package_id }}" VER="${{ steps.version_check.outputs.current_version }}" @@ -289,21 +296,8 @@ jobs: echo "::warning title=NuGet verification skipped::package_id not exported by version_check; skipping verify." exit 0 fi - PKG_LOWER=$(echo "$PKG" | tr '[:upper:]' '[:lower:]') - for i in 1 2 3 4 5 6; do - STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://api.nuget.org/v3-flatcontainer/${PKG_LOWER}/${VER}/${PKG_LOWER}.nuspec") - echo "Attempt $i: NuGet HTTP status for ${PKG}@${VER}: ${STATUS}" - if [ "$STATUS" = "200" ]; then - echo "✅ Verified ${PKG}@${VER} is on NuGet" - exit 0 - fi - sleep 10 - done - echo "::error title=NuGet verification failed::${PKG}@${VER} is not on NuGet after publish." - echo "The publish step reported success but the registry does not see the version." - echo "See docs/case-studies/issue-33/README.md for the runbook." - exit 1 + node scripts/wait-for-registry.mjs \ + --registry nuget --name "$PKG" --version "$VER" - name: Create GitHub Release if: steps.version_check.outputs.should_release == 'true' @@ -405,6 +399,25 @@ jobs: fi echo "$OUT" + - name: Verify package on NuGet + # Issue #41: the manual release path published without ever reading the + # package back, so a rejected or partial push would still have left a + # green run and a version number that does not exist on NuGet. Both + # release paths now verify the same way. + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + REGISTRY_WAIT_VERBOSE: '0' + run: | + PKG=$(sed -n 's|.*\(.*\).*|\1|p' \ + csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj | head -1) + VER="${{ steps.version.outputs.new_version }}" + if [ -z "$PKG" ] || [ -z "$VER" ]; then + echo "::error title=NuGet verification skipped::Could not resolve package id or version." + exit 1 + fi + node scripts/wait-for-registry.mjs \ + --registry nuget --name "$PKG" --version "$VER" + - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: From a797ada5f5563ea80f521489ecece93908ba4357 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:03:56 +0000 Subject: [PATCH 06/29] ci(python): lint the release helper scripts too python/scripts/ holds the code that performs the release, yet ruff only ever saw src and tests. Bringing it into scope surfaced two deprecated Optional annotations and seven files formatted to a line length the project does not use; both are fixed here so the wider scope starts green. --- .github/workflows/python.yml | 4 ++-- python/scripts/bump_version.py | 20 +++++--------------- python/scripts/check_file_size.py | 5 +---- python/scripts/create_manual_changeset.py | 8 ++------ python/scripts/format_release_notes.py | 9 +++++---- python/scripts/publish_to_pypi.py | 4 +--- python/scripts/validate_changeset.py | 8 ++------ python/scripts/version_and_commit.py | 4 +--- 8 files changed, 19 insertions(+), 43 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 95eb9cf..d5b0b89 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -55,11 +55,11 @@ jobs: - name: Run Ruff linting working-directory: ./python - run: ruff check src tests + run: ruff check src tests scripts - name: Check Ruff formatting working-directory: ./python - run: ruff format --check src tests + run: ruff format --check src tests scripts - name: Run mypy working-directory: ./python diff --git a/python/scripts/bump_version.py b/python/scripts/bump_version.py index 6850af3..e687ba5 100755 --- a/python/scripts/bump_version.py +++ b/python/scripts/bump_version.py @@ -49,22 +49,16 @@ def update_pyproject(pyproject_path: Path, old_version: str, new_version: str) - """Update version in pyproject.toml.""" content = pyproject_path.read_text() pattern = rf'^(version\s*=\s*["\']){re.escape(old_version)}(["\'])' - new_content = re.sub( - pattern, rf"\g<1>{new_version}\g<2>", content, flags=re.MULTILINE - ) + new_content = re.sub(pattern, rf"\g<1>{new_version}\g<2>", content, flags=re.MULTILINE) if content == new_content: - raise ValueError( - f"Failed to update version from {old_version} to {new_version}" - ) + raise ValueError(f"Failed to update version from {old_version} to {new_version}") pyproject_path.write_text(new_content) print(f"✓ Updated pyproject.toml: {old_version} → {new_version}") -def update_changelog( - changelog_path: Path, version: str, bump_type: str, description: str -) -> None: +def update_changelog(changelog_path: Path, version: str, bump_type: str, description: str) -> None: """Update CHANGELOG.md with new version entry.""" if not changelog_path.exists(): print(f"Warning: {changelog_path} not found, skipping changelog update") @@ -95,9 +89,7 @@ def update_changelog( main_heading_match = re.search(r"^# .+$", content, re.MULTILINE) if main_heading_match: insert_pos = main_heading_match.end() - new_content = ( - content[:insert_pos] + "\n\n" + new_entry + content[insert_pos:] - ) + new_content = content[:insert_pos] + "\n\n" + new_entry + content[insert_pos:] else: # Prepend if no headings at all new_content = new_entry + "\n" + content @@ -154,9 +146,7 @@ def main() -> int: print(f"\n✅ Version bump complete: {old_version} → {new_version}") print("\nNext steps:") print(" 1. Review changes: git diff") - print( - " 2. Commit: git add . && git commit -m 'chore: bump version to {new_version}'" - ) + print(" 2. Commit: git add . && git commit -m 'chore: bump version to {new_version}'") print(" 3. Tag: git tag v{new_version}") print(" 4. Push: git push && git push --tags") diff --git a/python/scripts/check_file_size.py b/python/scripts/check_file_size.py index e90ac79..65e259e 100755 --- a/python/scripts/check_file_size.py +++ b/python/scripts/check_file_size.py @@ -89,10 +89,7 @@ def main() -> None: else: print("✗ Found files exceeding the line limit:\n") for violation in violations: - print( - f" {violation['file']}: {violation['lines']} lines " - f"(exceeds {MAX_LINES})" - ) + print(f" {violation['file']}: {violation['lines']} lines (exceeds {MAX_LINES})") print(f"\nPlease refactor these files to be under {MAX_LINES} lines\n") sys.exit(1) diff --git a/python/scripts/create_manual_changeset.py b/python/scripts/create_manual_changeset.py index 083a88d..f6d3795 100644 --- a/python/scripts/create_manual_changeset.py +++ b/python/scripts/create_manual_changeset.py @@ -123,9 +123,7 @@ def create_with_scriv(bump_type: str, description: str) -> int: return 1 -def create_manual_fragment( - changelog_dir: Path, bump_type: str, description: str -) -> int: +def create_manual_fragment(changelog_dir: Path, bump_type: str, description: str) -> int: """Create a changelog fragment manually without scriv.""" # Generate filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -238,9 +236,7 @@ def main() -> int: else: if not args.no_scriv: print("Note: scriv not found, creating fragment manually") - print( - "Install scriv for better fragment management: pip install scriv[toml]" - ) + print("Install scriv for better fragment management: pip install scriv[toml]") print() return create_manual_fragment(changelog_dir, args.bump_type, args.description) diff --git a/python/scripts/format_release_notes.py b/python/scripts/format_release_notes.py index e965087..120297e 100644 --- a/python/scripts/format_release_notes.py +++ b/python/scripts/format_release_notes.py @@ -23,7 +23,6 @@ import re import subprocess import sys -from typing import Optional def run_gh_command(args: list[str]) -> tuple[bool, str]: @@ -50,7 +49,7 @@ def get_release_body(repository: str, release_id: str) -> tuple[bool, str]: return success, output.strip() if success else output -def find_pr_for_commit(repository: str, commit_sha: str) -> Optional[str]: +def find_pr_for_commit(repository: str, commit_sha: str) -> str | None: """Find the pull request that contains a specific commit.""" if not commit_sha: return None @@ -78,7 +77,7 @@ def format_release_body( body: str, version: str, repository: str, - pr_number: Optional[str], + pr_number: str | None, package_name: str, ) -> str: """Format the release body with enhanced information.""" @@ -99,7 +98,9 @@ def format_release_body( # Add PR link if available if pr_number: - pr_link = f"**Pull Request:** [#{pr_number}](https://github.com/{repository}/pull/{pr_number})" + pr_link = ( + f"**Pull Request:** [#{pr_number}](https://github.com/{repository}/pull/{pr_number})" + ) formatted_parts.append(pr_link) formatted_parts.append("") diff --git a/python/scripts/publish_to_pypi.py b/python/scripts/publish_to_pypi.py index 641bede..535a75e 100755 --- a/python/scripts/publish_to_pypi.py +++ b/python/scripts/publish_to_pypi.py @@ -33,9 +33,7 @@ def run_command(cmd: list[str], check: bool = True) -> subprocess.CompletedProce print(result.stderr, file=sys.stderr) if check and result.returncode != 0: - print( - f"Error: Command failed with exit code {result.returncode}", file=sys.stderr - ) + print(f"Error: Command failed with exit code {result.returncode}", file=sys.stderr) sys.exit(result.returncode) return result diff --git a/python/scripts/validate_changeset.py b/python/scripts/validate_changeset.py index c968488..15bee63 100644 --- a/python/scripts/validate_changeset.py +++ b/python/scripts/validate_changeset.py @@ -104,9 +104,7 @@ def main() -> int: print(" scriv create") print() print(" # Or using the helper script:") - print( - " python scripts/create_manual_changeset.py patch --description 'Your changes'" - ) + print(" python scripts/create_manual_changeset.py patch --description 'Your changes'") print() print("See changelog.d/README.md for more information.") print() @@ -117,9 +115,7 @@ def main() -> int: if fragment_count > 1: print() - print( - f"WARNING: Found {fragment_count} fragments. Usually PRs should have only one." - ) + print(f"WARNING: Found {fragment_count} fragments. Usually PRs should have only one.") print("Fragments found:") for f in fragments: print(f" - {f.name}") diff --git a/python/scripts/version_and_commit.py b/python/scripts/version_and_commit.py index 5080e82..7064faf 100755 --- a/python/scripts/version_and_commit.py +++ b/python/scripts/version_and_commit.py @@ -218,9 +218,7 @@ def main() -> int: # Push to main run_command(["git", "push", "origin", "main"]) - print( - f"\n✅ Version bump committed and pushed: {old_version} → {new_version}" - ) + print(f"\n✅ Version bump committed and pushed: {old_version} → {new_version}") set_github_output("version_committed", "true") else: print("\nNo changes to commit") From 097056a973186f25da339c04289e1714b3207c11 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:04:55 +0000 Subject: [PATCH 07/29] ci(python): explain the PyPI publish failure and widen the readback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.2.0 release failed with 'invalid-publisher: valid token, but no corresponding publisher'. https://pypi.org/pypi/lino-objects-codec/json answers 404, so the project has never been published, and PyPI needs a *pending* publisher registered before the first upload of a project that does not exist yet. That is a one-time action in PyPI's settings, so the repository cannot fix it — but it can stop hiding it. A preflight now prints the four claims PyPI will be asked to match and the steps to register them, and a failure handler repeats them with the claims dumped. Both report without gating, because a correctly registered pending publisher looks exactly like none at all from outside until the first upload lands. A PYPI_API_TOKEN secret, when set, carries the upload instead of OIDC so a first release is possible before trusted publishing is registered. The PyPI readback also moves off its twenty-five second budget onto the shared registry wait. --- .github/workflows/python.yml | 112 ++++++++--- python/scripts/pypi_publish_preflight.py | 201 ++++++++++++++++++++ python/tests/test_pypi_publish_preflight.py | 164 ++++++++++++++++ 3 files changed, 446 insertions(+), 31 deletions(-) create mode 100644 python/scripts/pypi_publish_preflight.py create mode 100644 python/tests/test_pypi_publish_preflight.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d5b0b89..4d6d6b3 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -197,6 +197,13 @@ jobs: with: python-version: '3.13' + - name: Setup Node.js + # The post-publish registry readback is a shared Node helper, so the + # release jobs need Node even though the package itself is Python. + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -232,37 +239,63 @@ jobs: name: dist path: python/dist/ + - name: PyPI publish preflight + # Issue #41: the 0.2.0 release died inside the publish action with + # `invalid-publisher: valid token, but no corresponding publisher`, + # which names none of the values an operator has to enter on PyPI and + # does not say the missing piece lives outside this repository. This + # step prints the four claims and the registration steps up front. + # It reports without gating: a correctly registered *pending* publisher + # is indistinguishable from none at all until the first upload lands. + if: steps.version_check.outputs.should_release == 'true' + working-directory: ./python + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + # Switch to '1' to dump the resolved OIDC claims; quiet by default. + PYPI_PREFLIGHT_VERBOSE: '0' + run: | + python scripts/pypi_publish_preflight.py \ + --package "${{ steps.version_check.outputs.package_name }}" + - name: Publish to PyPI id: pypi_publish if: steps.version_check.outputs.should_release == 'true' # Uses PyPI's OIDC Trusted Publisher flow (id-token: write below). If you see "Trusted # Publisher … not configured" in the logs, configure it for this repo + workflow on PyPI. # See docs/case-studies/issue-25/README.md for the broader context. + # A PYPI_API_TOKEN secret, when present, takes over so a first release + # can happen before trusted publishing is registered; without the + # secret the `password` input is empty and OIDC is used as before. uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: python/dist/ + password: ${{ secrets.PYPI_API_TOKEN }} verbose: true skip-existing: true + - name: Explain a failed publish + # Turns the raw OIDC exchange error into the runbook. Without this the + # only guidance in the log is a list of claims and a 'not found'. + if: failure() && steps.pypi_publish.outcome == 'failure' + working-directory: ./python + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + PYPI_PREFLIGHT_VERBOSE: '1' + run: | + python scripts/pypi_publish_preflight.py \ + --package "${{ steps.version_check.outputs.package_name }}" + - name: Verify package on PyPI + # Issue #41: this readback allowed twenty-five seconds, the same class + # of too-short budget that turned a successful NuGet publish into a red + # run. It now uses the shared, per-registry-tuned readback. if: steps.version_check.outputs.should_release == 'true' && steps.pypi_publish.outcome == 'success' + env: + REGISTRY_WAIT_VERBOSE: '0' run: | - PKG="${{ steps.version_check.outputs.package_name }}" - VER="${{ steps.version_check.outputs.current_version }}" - # PyPI's CDN can lag a few seconds behind a successful upload. - for i in 1 2 3 4 5; do - STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PKG}/${VER}/json") - echo "Attempt $i: PyPI HTTP status for ${PKG}@${VER}: ${STATUS}" - if [ "$STATUS" = "200" ]; then - echo "✅ Verified ${PKG}@${VER} is on PyPI" - exit 0 - fi - sleep 5 - done - echo "::error title=PyPI verification failed::${PKG}@${VER} is not on PyPI after publish." - echo "The publish step reported success but the registry does not see the version." - echo "See docs/case-studies/issue-29/README.md for the runbook." - exit 1 + node scripts/wait-for-registry.mjs --registry pypi \ + --name "${{ steps.version_check.outputs.package_name }}" \ + --version "${{ steps.version_check.outputs.current_version }}" - name: Create GitHub Release if: steps.version_check.outputs.should_release == 'true' @@ -302,6 +335,12 @@ jobs: python -m pip install --upgrade pip pip install build twine "scriv[toml]" + - name: Setup Node.js + # The post-publish registry readback is a shared Node helper. + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Configure git run: | git config user.name "github-actions[bot]" @@ -345,6 +384,16 @@ jobs: PACKAGE_NAME=$(grep -Po '(?<=^name = ")[^"]*' pyproject.toml | head -1) echo "package_name=$PACKAGE_NAME" >> $GITHUB_OUTPUT + - name: PyPI publish preflight + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + working-directory: ./python + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + PYPI_PREFLIGHT_VERBOSE: '0' + run: | + python scripts/pypi_publish_preflight.py \ + --package "${{ steps.pkg.outputs.package_name }}" + - name: Publish to PyPI id: pypi_publish if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' @@ -352,27 +401,28 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: python/dist/ + password: ${{ secrets.PYPI_API_TOKEN }} verbose: true skip-existing: true + - name: Explain a failed publish + if: failure() && steps.pypi_publish.outcome == 'failure' + working-directory: ./python + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + PYPI_PREFLIGHT_VERBOSE: '1' + run: | + python scripts/pypi_publish_preflight.py \ + --package "${{ steps.pkg.outputs.package_name }}" + - name: Verify package on PyPI if: (steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true') && steps.pypi_publish.outcome == 'success' + env: + REGISTRY_WAIT_VERBOSE: '0' run: | - PKG="${{ steps.pkg.outputs.package_name }}" - VER="${{ steps.version.outputs.new_version }}" - for i in 1 2 3 4 5; do - STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PKG}/${VER}/json") - echo "Attempt $i: PyPI HTTP status for ${PKG}@${VER}: ${STATUS}" - if [ "$STATUS" = "200" ]; then - echo "✅ Verified ${PKG}@${VER} is on PyPI" - exit 0 - fi - sleep 5 - done - echo "::error title=PyPI verification failed::${PKG}@${VER} is not on PyPI after publish." - echo "The publish step reported success but the registry does not see the version." - echo "See docs/case-studies/issue-29/README.md for the runbook." - exit 1 + node scripts/wait-for-registry.mjs --registry pypi \ + --name "${{ steps.pkg.outputs.package_name }}" \ + --version "${{ steps.version.outputs.new_version }}" - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' diff --git a/python/scripts/pypi_publish_preflight.py b/python/scripts/pypi_publish_preflight.py new file mode 100644 index 0000000..0787f0e --- /dev/null +++ b/python/scripts/pypi_publish_preflight.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Explain, before the upload runs, why a PyPI trusted publish is about to fail. + +Issue #41. The release job for 0.2.0 died inside +``pypa/gh-action-pypi-publish`` with:: + + Trusted publishing exchange failure: + invalid-publisher: valid token, but no corresponding publisher + (Publisher with matching claims was not found) + * sub: repo:link-foundation/lino-objects-codec:ref:refs/heads/main + * workflow_ref: .../.github/workflows/python.yml@refs/heads/main + * environment: MISSING + +The token was valid; PyPI simply had no publisher record to match it against. +``https://pypi.org/pypi/lino-objects-codec/json`` answers 404, so the project +has never been published, and PyPI requires a *pending* publisher to be +registered before the very first upload of a project that does not exist yet +(https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). + +That is a one-time action in PyPI's account settings, so no code change can fix +it. What code can fix is the diagnosis: the raw OIDC error names none of the +four claims an operator has to copy into the PyPI form, and does not say that +the missing piece lives outside the repository at all. + +This preflight prints exactly those four claims and the steps to register them. + +It is deliberately **advisory**: it never fails the build. A correctly +registered pending publisher is invisible from the outside — the project is +still absent from PyPI until the first upload succeeds — so treating "project +not found" as an error would reject the one configuration that is about to +work, trading an opaque failure for a false negative. + +Verbose mode (``--verbose`` or ``PYPI_PREFLIGHT_VERBOSE=1``) additionally dumps +the resolved OIDC claims so the next mismatch names itself. It is off by +default. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from collections.abc import Callable + +PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json" +USER_AGENT = "lino-objects-codec-ci (+https://github.com/link-foundation/lino-objects-codec)" + + +def _default_fetch(url: str, timeout: float = 15.0) -> int: + """Return the HTTP status for ``url``, or 0 when the request never lands.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return int(response.status) + except urllib.error.HTTPError as error: + return int(error.code) + except OSError: + return 0 + + +def project_exists(name: str, fetch: Callable[[str], int] = _default_fetch) -> bool | None: + """Is ``name`` a project that already exists on PyPI? + + Returns ``None`` when the question could not be answered — a network + failure must not be reported as "the project is missing". + """ + status = fetch(PYPI_JSON_URL.format(name=name)) + if status == 200: + return True + if status == 404: + return False + return None + + +def resolve_claims(env: dict[str, str]) -> dict[str, str]: + """Reconstruct the OIDC claims PyPI will be asked to match. + + These mirror what GitHub puts in the token and what the PyPI publisher form + asks for, so the two can be compared field by field. + """ + repository = env.get("GITHUB_REPOSITORY", "") + owner, _, name = repository.partition("/") + workflow_ref = env.get("GITHUB_WORKFLOW_REF", "") + # `owner/repo/.github/workflows/python.yml@refs/heads/main` -> `python.yml` + workflow_file = "" + if workflow_ref: + workflow_file = workflow_ref.split("@")[0].rsplit("/", 1)[-1] + return { + "owner": owner, + "repository": name, + "workflow": workflow_file, + # PyPI matches an empty environment only against a publisher that was + # registered without one. The run that failed reported `MISSING`. + "environment": env.get("PYPI_PUBLISH_ENVIRONMENT", ""), + "ref": env.get("GITHUB_REF", ""), + } + + +def build_runbook(package: str, claims: dict[str, str], has_token: bool) -> str: + """The operator-facing instructions for registering a pending publisher.""" + environment = claims["environment"] or "(leave blank)" + lines = [ + f'PyPI has no project named "{package}" yet.', + "", + "PyPI cannot attach a trusted publisher to a project that does not exist,", + "so the first upload needs a *pending* publisher registered beforehand.", + "Without one the upload fails with:", + " invalid-publisher: valid token, but no corresponding publisher", + "", + "How to fix (one-time, outside this repository):", + " 1. Sign in to PyPI and open", + " https://pypi.org/manage/account/publishing/", + ' 2. Under "Add a new pending publisher", choose GitHub and enter:', + f" PyPI Project Name: {package}", + f" Owner: {claims['owner']}", + f" Repository name: {claims['repository']}", + f" Workflow name: {claims['workflow']}", + f" Environment name: {environment}", + " 3. Re-run this workflow.", + "", + "All five values must match exactly; a publisher registered *with* an", + "environment will not match a workflow that declares none, and vice versa.", + "", + "Alternative for a one-off first release: set the PYPI_API_TOKEN secret", + "(https://pypi.org/manage/account/token/) and this workflow will upload", + "with the token instead of OIDC.", + ] + if has_token: + lines += [ + "", + "PYPI_API_TOKEN is configured, so this run will publish with the token", + "and the missing publisher will not block it.", + ] + lines += [ + "", + "Background: https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/", + " dev/log/issues/41/pulls/42/ANALYSIS.md", + ] + return "\n".join(lines) + + +def _set_output(name: str, value: str) -> None: + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def main( + argv: list[str] | None = None, + env: dict[str, str] | None = None, + fetch: Callable[[str], int] = _default_fetch, + out: Callable[[str], None] = print, +) -> int: + """Always returns 0: this step reports, it does not gate.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", required=True, help="PyPI project name") + parser.add_argument( + "--verbose", + action="store_true", + help="print the resolved OIDC claims (default off)", + ) + args = parser.parse_args(argv) + + env = dict(os.environ if env is None else env) + verbose = args.verbose or env.get("PYPI_PREFLIGHT_VERBOSE", "") not in ("", "0", "false") + + claims = resolve_claims(env) + has_token = bool(env.get("PYPI_API_TOKEN")) + + if verbose: + out("PyPI preflight: resolved OIDC claims") + out(json.dumps(claims, indent=2, sort_keys=True)) + out(f"PyPI preflight: PYPI_API_TOKEN configured: {has_token}") + out(f"PyPI preflight: probing {PYPI_JSON_URL.format(name=args.package)}") + + exists = project_exists(args.package, fetch) + _set_output("project_exists", "unknown" if exists is None else str(exists).lower()) + + if exists is None: + out( + "::warning title=PyPI preflight inconclusive::" + f"Could not reach PyPI to check whether {args.package} exists; continuing." + ) + return 0 + + if exists: + out(f"PyPI preflight: project {args.package} exists; trusted publishing can match it.") + return 0 + + runbook = build_runbook(args.package, claims, has_token) + out(f"::warning title=PyPI project does not exist yet::{runbook.splitlines()[0]}") + out(runbook) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/tests/test_pypi_publish_preflight.py b/python/tests/test_pypi_publish_preflight.py new file mode 100644 index 0000000..367f0e9 --- /dev/null +++ b/python/tests/test_pypi_publish_preflight.py @@ -0,0 +1,164 @@ +"""Tests for the PyPI publish preflight (issue #41).""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "pypi_publish_preflight.py" +_spec = importlib.util.spec_from_file_location("pypi_publish_preflight", _SCRIPT) +assert _spec is not None and _spec.loader is not None +preflight = importlib.util.module_from_spec(_spec) +sys.modules["pypi_publish_preflight"] = preflight +_spec.loader.exec_module(preflight) + + +# The claims GitHub actually sent in the run that failed, run 32345106245. +FAILING_RUN_ENV = { + "GITHUB_REPOSITORY": "link-foundation/lino-objects-codec", + "GITHUB_WORKFLOW_REF": ( + "link-foundation/lino-objects-codec/.github/workflows/python.yml@refs/heads/main" + ), + "GITHUB_REF": "refs/heads/main", +} + + +def _fetch(status: int): + def fetch(url: str) -> int: + fetch.calls.append(url) + return status + + fetch.calls = [] + return fetch + + +def _capture(): + lines: list[str] = [] + return lines, lines.append + + +def test_missing_project_is_detected(): + assert preflight.project_exists("lino-objects-codec", _fetch(404)) is False + + +def test_existing_project_is_detected(): + assert preflight.project_exists("requests", _fetch(200)) is True + + +def test_unreachable_pypi_is_not_reported_as_missing(): + # A network failure must not be turned into "the project does not exist", + # which would print a runbook for a problem that may not exist. + assert preflight.project_exists("lino-objects-codec", _fetch(0)) is None + assert preflight.project_exists("lino-objects-codec", _fetch(503)) is None + + +def test_probe_targets_the_pypi_json_api(): + fetch = _fetch(404) + preflight.project_exists("lino-objects-codec", fetch) + assert fetch.calls == ["https://pypi.org/pypi/lino-objects-codec/json"] + + +def test_claims_match_the_ones_pypi_rejected(): + claims = preflight.resolve_claims(FAILING_RUN_ENV) + assert claims["owner"] == "link-foundation" + assert claims["repository"] == "lino-objects-codec" + assert claims["workflow"] == "python.yml" + # The failing run reported `environment: MISSING`; with no environment + # declared, the publisher on PyPI must also be registered without one. + assert claims["environment"] == "" + + +def test_environment_claim_is_reported_when_the_workflow_declares_one(): + env = {**FAILING_RUN_ENV, "PYPI_PUBLISH_ENVIRONMENT": "release"} + assert preflight.resolve_claims(env)["environment"] == "release" + + +def test_runbook_names_every_value_the_pypi_form_asks_for(): + claims = preflight.resolve_claims(FAILING_RUN_ENV) + runbook = preflight.build_runbook("lino-objects-codec", claims, has_token=False) + for expected in ( + "lino-objects-codec", + "link-foundation", + "python.yml", + "(leave blank)", + "https://pypi.org/manage/account/publishing/", + "pending publisher", + ): + assert expected in runbook + + +def test_runbook_says_a_token_will_carry_this_run(): + claims = preflight.resolve_claims(FAILING_RUN_ENV) + assert "PYPI_API_TOKEN is configured" in preflight.build_runbook( + "lino-objects-codec", claims, has_token=True + ) + assert "PYPI_API_TOKEN is configured" not in preflight.build_runbook( + "lino-objects-codec", claims, has_token=False + ) + + +def test_preflight_reports_but_never_gates(): + # A pending publisher is invisible from outside PyPI: the project stays + # absent until the first upload succeeds. Failing here would reject the + # very configuration that is about to work. + lines, out = _capture() + code = preflight.main( + ["--package", "lino-objects-codec"], env=FAILING_RUN_ENV, fetch=_fetch(404), out=out + ) + assert code == 0 + assert any("PyPI project does not exist yet" in line for line in lines) + + +def test_existing_project_produces_no_runbook(): + lines, out = _capture() + code = preflight.main( + ["--package", "lino-objects-codec"], env=FAILING_RUN_ENV, fetch=_fetch(200), out=out + ) + assert code == 0 + assert not any("pending publisher" in line for line in lines) + + +def test_unreachable_pypi_warns_and_continues(): + lines, out = _capture() + code = preflight.main( + ["--package", "lino-objects-codec"], env=FAILING_RUN_ENV, fetch=_fetch(0), out=out + ) + assert code == 0 + assert any("PyPI preflight inconclusive" in line for line in lines) + + +def test_verbose_is_off_by_default(): + lines, out = _capture() + preflight.main( + ["--package", "lino-objects-codec"], env=FAILING_RUN_ENV, fetch=_fetch(200), out=out + ) + assert not any("resolved OIDC claims" in line for line in lines) + + +@pytest.mark.parametrize("switch", ["flag", "env"]) +def test_verbose_dumps_the_claims(switch: str): + lines, out = _capture() + argv = ["--package", "lino-objects-codec"] + env = dict(FAILING_RUN_ENV) + if switch == "flag": + argv.append("--verbose") + else: + env["PYPI_PREFLIGHT_VERBOSE"] = "1" + preflight.main(argv, env=env, fetch=_fetch(200), out=out) + body = "\n".join(lines) + assert "resolved OIDC claims" in body + assert "link-foundation" in body + + +def test_verbose_env_var_respects_an_explicit_off(): + lines, out = _capture() + preflight.main( + ["--package", "lino-objects-codec"], + env={**FAILING_RUN_ENV, "PYPI_PREFLIGHT_VERBOSE": "0"}, + fetch=_fetch(200), + out=out, + ) + assert not any("resolved OIDC claims" in line for line in lines) From 6b1e962f3511ad3f59c9f12419a7f736ec4d0fef Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:05:44 +0000 Subject: [PATCH 08/29] ci: verify the crates.io publish and test the shared scripts Rust was the only language that published without reading the result back, so a rejected upload would still have produced a green run, a tag and a release for a version nobody can depend on. The shared scripts/ helpers had no workflow of their own either: only the pull-request-only parity job ran a test, and only one file of it. --- .github/workflows/rust.yml | 24 +++++++++++++++++++ .github/workflows/scripts.yml | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/scripts.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 683caaf..8cbc92d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -362,6 +362,21 @@ jobs: fi echo "$OUT" + - name: Verify crate on crates.io + # Issue #41: Rust was the only language that published without ever + # reading the result back, so a rejected or half-completed upload would + # still have produced a green run, a git tag and a GitHub release for a + # version nobody can depend on. JavaScript, Python and C# all verify; + # now Rust does too. + if: steps.release_check.outputs.should_release == 'true' + env: + REGISTRY_WAIT_VERBOSE: '0' + run: | + CRATE=$(sed -n 's|^name = "\(.*\)"|\1|p' rust/Cargo.toml | head -1) + VER=$(sed -n 's|^version = "\(.*\)"|\1|p' rust/Cargo.toml | head -1) + node scripts/wait-for-registry.mjs \ + --registry crates --name "$CRATE" --version "$VER" + - name: Create GitHub Release if: steps.release_check.outputs.should_release == 'true' env: @@ -465,6 +480,15 @@ jobs: fi echo "$OUT" + - name: Verify crate on crates.io + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + env: + REGISTRY_WAIT_VERBOSE: '0' + run: | + CRATE=$(sed -n 's|^name = "\(.*\)"|\1|p' rust/Cargo.toml | head -1) + node scripts/wait-for-registry.mjs --registry crates \ + --name "$CRATE" --version "${{ steps.version.outputs.new_version }}" + - name: Create GitHub Release if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' env: diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 0000000..0cbf1a3 --- /dev/null +++ b/.github/workflows/scripts.yml @@ -0,0 +1,44 @@ +name: Shared CI Scripts + +# The repository-level `scripts/` directory holds helpers that every language's +# pipeline depends on: the cross-language parity gate and the post-publish +# registry readback. Until issue #41 the only thing that ran their tests was the +# parity workflow, which is pull-request-only and named a single test file, so a +# broken helper could reach `main` untested and only surface during a release. +# +# This workflow runs every `scripts/*.test.mjs`, on pull requests and on pushes +# to `main`. It has no `paths:` filter on purpose — a change anywhere can break +# a shared helper, and a filtered workflow is a check that silently does not run. + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +# Least privilege: this workflow only ever reads the repository. +permissions: + contents: read + +# A read-only check, so superseded runs are safe to cancel. +concurrency: + group: check-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test Shared Scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run shared script tests + run: node --test scripts/*.test.mjs From fa867adeb8b94793368a4218886f5fee3c20570c Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:09:07 +0000 Subject: [PATCH 09/29] ci: bump all actions to Node 24 majors and fix codecov inputs - checkout v4->v7, setup-node v4->v7, setup-python v5->v7, setup-dotnet v4->v6, upload-artifact v4->v7, download-artifact v4->v8, cache v4->v6, codecov-action v4->v7, create-pull-request v7->v8. - codecov v5 renamed 'file' to 'files'; rename the input and pass CODECOV_TOKEN so protected-branch uploads stop warning. --- .github/workflows/csharp.yml | 35 +++++++++++++++--------------- .github/workflows/js.yml | 30 +++++++++++++------------- .github/workflows/parity.yml | 4 ++-- .github/workflows/python.yml | 37 ++++++++++++++++---------------- .github/workflows/rust.yml | 40 +++++++++++++++++------------------ .github/workflows/scripts.yml | 4 ++-- 6 files changed, 76 insertions(+), 74 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 844690a..c0bcf57 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -42,10 +42,10 @@ jobs: name: Lint and Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' @@ -70,10 +70,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' @@ -95,9 +95,10 @@ jobs: - name: Upload coverage (Ubuntu only) if: matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} # Build NuGet package build: @@ -105,10 +106,10 @@ jobs: runs-on: ubuntu-latest needs: [lint, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' @@ -125,7 +126,7 @@ jobs: run: dotnet pack --configuration Release --no-build --output ./artifacts - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget-package path: csharp/artifacts/*.nupkg @@ -136,12 +137,12 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -187,17 +188,17 @@ jobs: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -228,7 +229,7 @@ jobs: - name: Download artifacts if: steps.version_check.outputs.should_release == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: nuget-package path: csharp/artifacts/ @@ -321,18 +322,18 @@ jobs: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 089ddb7..cd34921 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -53,7 +53,7 @@ jobs: workflow-changed: ${{ steps.changes.outputs.workflow-changed }} any-code-changed: ${{ steps.changes.outputs.any-code-changed }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -92,12 +92,12 @@ jobs: needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -138,10 +138,10 @@ jobs: needs.detect-changes.outputs.workflow-changed == 'true' ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -173,10 +173,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -206,12 +206,12 @@ jobs: pull-requests: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' registry-url: 'https://registry.npmjs.org' @@ -281,12 +281,12 @@ jobs: pull-requests: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' registry-url: 'https://registry.npmjs.org' @@ -334,12 +334,12 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -360,7 +360,7 @@ jobs: echo "Formatted changeset files" - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index c45f909..43ece0d 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -25,12 +25,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4d6d6b3..58baa04 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -40,10 +40,10 @@ jobs: name: Lint and Format Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.13' @@ -74,10 +74,10 @@ jobs: name: Test (Python 3.13) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.13' @@ -92,10 +92,11 @@ jobs: run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: - file: ./python/coverage.xml + files: ./python/coverage.xml fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} # Build package - only runs if lint and test pass build: @@ -103,10 +104,10 @@ jobs: runs-on: ubuntu-latest needs: [lint, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.13' @@ -124,7 +125,7 @@ jobs: run: twine check dist/* - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist path: python/dist/ @@ -135,12 +136,12 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.13" @@ -188,19 +189,19 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.13' - name: Setup Node.js # The post-publish registry readback is a shared Node helper, so the # release jobs need Node even though the package itself is Python. - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -234,7 +235,7 @@ jobs: - name: Download artifacts if: steps.version_check.outputs.should_release == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist path: python/dist/ @@ -319,13 +320,13 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: '3.13' @@ -337,7 +338,7 @@ jobs: - name: Setup Node.js # The post-publish registry readback is a shared Node helper. - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8cbc92d..cb80b2f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -57,12 +57,12 @@ jobs: workflow-changed: ${{ steps.changes.outputs.workflow-changed }} any-code-changed: ${{ steps.changes.outputs.any-code-changed }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -83,12 +83,12 @@ jobs: needs: [detect-changes] if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -105,12 +105,12 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -140,7 +140,7 @@ jobs: needs.detect-changes.outputs.workflow-changed == 'true' ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -148,12 +148,12 @@ jobs: components: rustfmt, clippy - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/bin/ @@ -192,13 +192,13 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/bin/ @@ -228,13 +228,13 @@ jobs: needs: [lint, test] if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Rust uses: dtolnay/rust-toolchain@stable - name: Cache cargo dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/bin/ @@ -266,7 +266,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -275,7 +275,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -403,7 +403,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -412,7 +412,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -510,12 +510,12 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' @@ -538,7 +538,7 @@ jobs: echo "Created changelog fragment: $FILENAME" - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: 'chore: add changelog for manual ${{ github.event.inputs.bump_type }} release' diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml index 0cbf1a3..a3734bd 100644 --- a/.github/workflows/scripts.yml +++ b/.github/workflows/scripts.yml @@ -33,10 +33,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' From b86f5959cc5769bca8c5b7546e9767738f326afd Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:10:28 +0000 Subject: [PATCH 10/29] build: pack the NuGet readme and fix Python licence metadata - csproj: PackageReadmeFile + packed README.md removes 'warn : Readme missing' from 'dotnet pack'. - pyproject: replace the deprecated 'license = {file = ...}' TOML table with the PEP 639 SPDX expression plus 'license-files', and add python/LICENSE so setuptools stops warning that the file cannot be found. --- .../Lino.Objects.Codec.csproj | 6 +++++ python/LICENSE | 24 +++++++++++++++++++ python/pyproject.toml | 5 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 python/LICENSE diff --git a/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj b/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj index 6439efe..89ad37c 100644 --- a/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj +++ b/csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj @@ -13,8 +13,14 @@ https://github.com/link-foundation/lino-objects-codec.git links-notation;serialization;codec;object-graph;circular-references true + README.md + + + + + diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/python/pyproject.toml b/python/pyproject.toml index 4cf4575..0363cef 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,8 @@ version = "0.2.0" description = "A library to encode/decode objects to/from links notation" readme = "README.md" requires-python = ">=3.13" -license = {file = "LICENSE"} +license = "Unlicense" +license-files = ["LICENSE"] authors = [ {name = "Link Foundation"} ] From bf90ac201c681bba4e9bce7e1718e967894c3ed6 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:11:16 +0000 Subject: [PATCH 11/29] chore(js): npm audit fix to clear 7 advisories (2 moderate, 5 high) --- js/package-lock.json | 58 ++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index 0a2f734..56d591c 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -886,9 +886,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1033,9 +1033,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1933,9 +1933,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -2427,10 +2427,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2828,9 +2838,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3133,9 +3143,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3439,9 +3449,9 @@ } }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -4050,9 +4060,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { From 4ff3717bdd955074b59e3766694a256888e14bdc Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:14:06 +0000 Subject: [PATCH 12/29] refactor(js): split the codec dispatch to clear ESLint complexity warnings _encodeValue (complexity 23) and _decodeLink (complexity 55, 95 statements) tripped the repository's own complexity/max-statements rules on every CI run. Extract the memoisation, scalar and collection handling into focused helpers so the two entry points become plain dispatch. Pure refactor: no behaviour or API change, all 244 tests and the example still pass, and 'eslint .' is now clean. This touches js/src/ only, so the pull request carries [skip-parity]: the other three codecs are unchanged because their linters report no such warning. --- js/src/codec.js | 547 +++++++++++++++++++++++++++--------------------- 1 file changed, 303 insertions(+), 244 deletions(-) diff --git a/js/src/codec.js b/js/src/codec.js index 11a6b36..d457d42 100644 --- a/js/src/codec.js +++ b/js/src/codec.js @@ -210,6 +210,118 @@ export class ObjectCodec { return this._decodeLink(link); } + /** + * Assign a fresh `obj_N` id to a mutable value and memoise it. + * @param {object} obj - The value to identify + * @returns {string} The assigned reference id + */ + _assignEncodeId(obj) { + const refId = `obj_${this._encodeCounter}`; + this._encodeCounter += 1; + this._encodeMemo.set(obj, refId); + return refId; + } + + /** + * Resolve the memoisation state of a value before it is encoded. + * + * @param {*} obj - The value about to be encoded + * @param {Set} visited - Objects currently being encoded on this branch + * @returns {{ref: (Link|null), visited: Set}} `ref` is a direct reference link + * when the value was already encoded (shared value or cycle), in which case + * the caller must return it instead of encoding the value again. `visited` + * is the set to pass down to nested values. + */ + _prepareEncodeReference(obj, visited) { + if (obj === null || typeof obj !== 'object') { + return { ref: null, visited }; + } + + if (this._encodeMemo.has(obj)) { + // Return a direct reference using the object's ID + return { ref: new Link(this._encodeMemo.get(obj)), visited }; + } + + if (!this._needsId.has(obj)) { + return { ref: null, visited }; + } + + if (visited.has(obj)) { + // We're in a cycle, create a direct reference + return { ref: new Link(this._assignEncodeId(obj)), visited }; + } + + const nested = new Set([...visited, obj]); + this._assignEncodeId(obj); + return { ref: null, visited: nested }; + } + + /** + * Encode a number, including the special float values. + * @param {number} value - The number to encode + * @returns {Link} Link object + */ + _encodeNumber(value) { + if (Number.isNaN(value)) { + return this._makeLink(ObjectCodec.TYPE_FLOAT, 'NaN'); + } + if (!Number.isFinite(value)) { + return this._makeLink( + ObjectCodec.TYPE_FLOAT, + value > 0 ? 'Infinity' : '-Infinity' + ); + } + if (Number.isInteger(value)) { + return this._makeLink(ObjectCodec.TYPE_INT, String(value)); + } + return this._makeLink(ObjectCodec.TYPE_FLOAT, String(value)); + } + + /** + * Wrap encoded collection members in a type marker link, using the + * self-reference format `(obj_id: type ...)` when the collection has an id. + * + * @param {object} obj - The collection being encoded + * @param {string} typeMarker - Type marker for the collection + * @param {Link[]} parts - Encoded members + * @returns {Link} Link object + */ + _wrapEncodedCollection(obj, typeMarker, parts) { + const marker = new Link(typeMarker); + if (this._encodeMemo.has(obj)) { + return new Link(this._encodeMemo.get(obj), [marker, ...parts]); + } + return new Link(undefined, [marker, ...parts]); + } + + /** + * Encode an array into a Link. + * @param {Array} obj - The array to encode + * @param {Set} visited - Objects currently being encoded on this branch + * @returns {Link} Link object + */ + _encodeArray(obj, visited) { + const parts = obj.map((item) => this._encodeValue(item, visited)); + return this._wrapEncodedCollection(obj, ObjectCodec.TYPE_ARRAY, parts); + } + + /** + * Encode a plain object into a Link. + * @param {object} obj - The object to encode + * @param {Set} visited - Objects currently being encoded on this branch + * @returns {Link} Link object + */ + _encodeObject(obj, visited) { + const parts = Object.entries(obj).map( + ([key, value]) => + new Link(undefined, [ + this._encodeValue(key, visited), + this._encodeValue(value, visited), + ]) + ); + return this._wrapEncodedCollection(obj, ObjectCodec.TYPE_OBJECT, parts); + } + /** * Encode a value into a Link. * @param {*} obj - The value to encode @@ -217,311 +329,258 @@ export class ObjectCodec { * @returns {Link} Link object */ _encodeValue(obj, visited = new Set()) { - // Check if we've seen this object before (for circular references and shared objects) - // Only track objects and arrays (mutable types) - if (obj !== null && typeof obj === 'object') { - if (this._encodeMemo.has(obj)) { - // Return a direct reference using the object's ID - const refId = this._encodeMemo.get(obj); - return new Link(refId); - } - - // For mutable objects that need IDs, assign them - if (this._needsId.has(obj)) { - if (visited.has(obj)) { - // We're in a cycle, create a direct reference - if (!this._encodeMemo.has(obj)) { - // Assign an ID for this object - const refId = `obj_${this._encodeCounter}`; - this._encodeCounter += 1; - this._encodeMemo.set(obj, refId); - } - const refId = this._encodeMemo.get(obj); - return new Link(refId); - } - - // Add to visited set - visited = new Set([...visited, obj]); - - // Assign an ID to this object - const refId = `obj_${this._encodeCounter}`; - this._encodeCounter += 1; - this._encodeMemo.set(obj, refId); - } + // Check if we've seen this object before (for circular references and + // shared objects). Only objects and arrays (mutable types) are tracked. + const prepared = this._prepareEncodeReference(obj, visited); + if (prepared.ref) { + return prepared.ref; } + const nested = prepared.visited; // Encode based on type if (obj === null) { return this._makeLink(ObjectCodec.TYPE_NULL); } - if (obj === undefined) { return this._makeLink(ObjectCodec.TYPE_UNDEFINED); } - if (typeof obj === 'boolean') { return this._makeLink(ObjectCodec.TYPE_BOOL, String(obj)); } - if (typeof obj === 'number') { - // Handle special float values - if (Number.isNaN(obj)) { - return this._makeLink(ObjectCodec.TYPE_FLOAT, 'NaN'); - } - if (!Number.isFinite(obj)) { - if (obj > 0) { - return this._makeLink(ObjectCodec.TYPE_FLOAT, 'Infinity'); - } else { - return this._makeLink(ObjectCodec.TYPE_FLOAT, '-Infinity'); - } - } - // Check if it's an integer - if (Number.isInteger(obj)) { - return this._makeLink(ObjectCodec.TYPE_INT, String(obj)); - } - return this._makeLink(ObjectCodec.TYPE_FLOAT, String(obj)); + return this._encodeNumber(obj); } - if (typeof obj === 'string') { // Encode strings as base64 to handle special characters, newlines, etc. const b64Encoded = Buffer.from(obj, 'utf-8').toString('base64'); return this._makeLink(ObjectCodec.TYPE_STR, b64Encoded); } - if (Array.isArray(obj)) { - const parts = []; - for (const item of obj) { - // Encode each item - const itemLink = this._encodeValue(item, visited); - parts.push(itemLink); - } - // If this array has an ID, use self-reference format: (obj_id: array item1 item2 ...) - if (this._encodeMemo.has(obj)) { - const refId = this._encodeMemo.get(obj); - // Return the inline definition with self-reference ID - return new Link(refId, [new Link(ObjectCodec.TYPE_ARRAY), ...parts]); - } else { - // Wrap in a type marker for arrays without IDs: (array item1 item2 ...) - return new Link(undefined, [ - new Link(ObjectCodec.TYPE_ARRAY), - ...parts, - ]); - } + return this._encodeArray(obj, nested); } - if (typeof obj === 'object') { - const parts = []; - for (const [key, value] of Object.entries(obj)) { - // Encode key and value - const keyLink = this._encodeValue(key, visited); - const valueLink = this._encodeValue(value, visited); - // Create a pair link - const pair = new Link(undefined, [keyLink, valueLink]); - parts.push(pair); - } - // If this object has an ID, use self-reference format: (obj_id: object (key val) ...) - if (this._encodeMemo.has(obj)) { - const refId = this._encodeMemo.get(obj); - // Return the inline definition with self-reference ID - return new Link(refId, [new Link(ObjectCodec.TYPE_OBJECT), ...parts]); - } else { - // Wrap in a type marker for objects without IDs: (object (key val) ...) - return new Link(undefined, [ - new Link(ObjectCodec.TYPE_OBJECT), - ...parts, - ]); - } + return this._encodeObject(obj, nested); } throw new TypeError(`Unsupported type: ${typeof obj}`); } /** - * Decode a Link into a JavaScript value. + * Decode a link that carries no values: an id, a forward reference or an + * empty document. + * * @param {Link} link - Link object to decode * @returns {*} Decoded JavaScript value */ - _decodeLink(link) { - // Check if this is a direct reference to a previously decoded object - // Direct references have an id but no values, or the id refers to an existing object - if (link.id && this._decodeMemo.has(link.id)) { + _decodeEmptyLink(link) { + if (!link.id) { + return null; + } + + // If it's in memo, return the cached object + if (this._decodeMemo.has(link.id)) { return this._decodeMemo.get(link.id); } - if (!link.values || link.values.length === 0) { - // Empty link - this might be a simple id, reference, or empty collection - if (link.id) { - // If it's in memo, return the cached object - if (this._decodeMemo.has(link.id)) { - return this._decodeMemo.get(link.id); - } - - // If it starts with obj_, check if we have a forward reference in _allLinks - if (link.id.startsWith('obj_') && this._allLinks.length > 0) { - // Look for this ID in the remaining links - for (const otherLink of this._allLinks) { - if (otherLink.id === link.id) { - // Found it! Decode it now - return this._decodeLink(otherLink); - } - } - - // Not found in links - create empty array as fallback - const result = []; - this._decodeMemo.set(link.id, result); - return result; - } - - // Otherwise it's just a string ID - return link.id; + // Otherwise it's just a string ID + if (!link.id.startsWith('obj_') || this._allLinks.length === 0) { + return link.id; + } + + // Look for this ID in the remaining links (forward reference) + for (const otherLink of this._allLinks) { + if (otherLink.id === link.id) { + return this._decodeLink(otherLink); } - return null; } - // Check if this link has a self-reference ID (format: obj_0: type ...) - let selfRefId = null; - if (link.id && link.id.startsWith('obj_')) { - selfRefId = link.id; + // Not found in links - create empty array as fallback + const result = []; + this._decodeMemo.set(link.id, result); + return result; + } + + /** + * Parse the textual payload of an encoded float, including the special + * values that JSON cannot represent. + * + * @param {string} raw - The encoded payload + * @returns {number} The decoded number + */ + static _parseEncodedFloat(raw) { + if (raw === 'NaN') { + return NaN; + } + if (raw === 'Infinity') { + return Infinity; + } + if (raw === '-Infinity') { + return -Infinity; } + return parseFloat(raw); + } - // Get the type marker from the first value - const firstValue = link.values[0]; - if (!firstValue || !firstValue.id) { - // Not a type marker we recognize - return null; + /** + * Decode a base64 payload, falling back to the raw value when it is not + * valid base64. + * + * @param {string} raw - The encoded payload + * @returns {string} The decoded string + */ + static _decodeBase64Payload(raw) { + try { + return Buffer.from(raw, 'base64').toString('utf-8'); + } catch { + // If decode fails, return the raw value + return raw; } + } - const typeMarker = firstValue.id; + /** + * Read the payload of a scalar link, i.e. the value after the type marker. + * + * @param {Link} link - Link object to read + * @returns {(string|null)} The payload, or null when the link carries none + */ + static _scalarPayload(link) { + const payload = link.values.length > 1 ? link.values[1] : null; + return payload && payload.id ? payload.id : null; + } - if (typeMarker === ObjectCodec.TYPE_NULL) { - return null; + /** + * Decode a scalar link (null, undefined, bool, int, float or string). + * + * @param {string} typeMarker - The type marker of the link + * @param {Link} link - Link object to decode + * @returns {*} Decoded JavaScript value + */ + static _decodeScalar(typeMarker, link) { + const raw = ObjectCodec._scalarPayload(link); + + switch (typeMarker) { + case ObjectCodec.TYPE_NULL: + return null; + case ObjectCodec.TYPE_UNDEFINED: + return undefined; + case ObjectCodec.TYPE_BOOL: + return raw === null ? false : raw.toLowerCase() === 'true'; + case ObjectCodec.TYPE_INT: + return raw === null ? 0 : parseInt(raw, 10); + case ObjectCodec.TYPE_FLOAT: + return raw === null ? 0.0 : ObjectCodec._parseEncodedFloat(raw); + case ObjectCodec.TYPE_STR: + return raw === null ? '' : ObjectCodec._decodeBase64Payload(raw); + default: + // Unknown type marker + throw new Error(`Unknown type marker: ${typeMarker}`); } + } - if (typeMarker === ObjectCodec.TYPE_UNDEFINED) { - return undefined; + /** + * Locate the id and the first member index of an encoded collection. + * + * Supports the current self-reference format `(obj_0: type item ...)` and the + * legacy format `(type obj_0 item ...)`. + * + * @param {Link} link - Link object to inspect + * @param {(string|null)} selfRefId - Id taken from the link itself, if any + * @returns {{id: (string|null), startIdx: number}} Collection id and offset + */ + static _collectionStart(link, selfRefId) { + if (selfRefId) { + return { id: selfRefId, startIdx: 1 }; } - if (typeMarker === ObjectCodec.TYPE_BOOL) { - if (link.values.length > 1) { - const boolValue = link.values[1]; - if (boolValue && boolValue.id) { - return boolValue.id.toLowerCase() === 'true'; - } - } - return false; + const second = link.values.length > 1 ? link.values[1] : null; + if (second && second.id && second.id.startsWith('obj_')) { + return { id: second.id, startIdx: 2 }; } - if (typeMarker === ObjectCodec.TYPE_INT) { - if (link.values.length > 1) { - const intValue = link.values[1]; - if (intValue && intValue.id) { - return parseInt(intValue.id, 10); - } - } - return 0; - } - - if (typeMarker === ObjectCodec.TYPE_FLOAT) { - if (link.values.length > 1) { - const floatValue = link.values[1]; - if (floatValue && floatValue.id) { - const valueStr = floatValue.id; - if (valueStr === 'NaN') { - return NaN; - } else if (valueStr === 'Infinity') { - return Infinity; - } else if (valueStr === '-Infinity') { - return -Infinity; - } else { - return parseFloat(valueStr); - } - } - } - return 0.0; - } - - if (typeMarker === ObjectCodec.TYPE_STR) { - if (link.values.length > 1) { - const strValue = link.values[1]; - if (strValue && strValue.id) { - const b64Str = strValue.id; - // Decode from base64 - try { - return Buffer.from(b64Str, 'base64').toString('utf-8'); - } catch { - // If decode fails, return the raw value - return b64Str; - } - } - } - return ''; + return { id: null, startIdx: 1 }; + } + + /** + * Decode an encoded array. + * @param {Link} link - Link object to decode + * @param {(string|null)} selfRefId - Self-reference id of the link, if any + * @returns {Array} Decoded array + */ + _decodeArray(link, selfRefId) { + const { id, startIdx } = ObjectCodec._collectionStart(link, selfRefId); + + const resultArray = []; + // Memoise before decoding members so cycles resolve to this same array. + if (id) { + this._decodeMemo.set(id, resultArray); } - if (typeMarker === ObjectCodec.TYPE_ARRAY) { - // New format with self-reference: (obj_0: array item1 item2 ...) - // Old format (for backward compatibility): (array obj_id item1 item2 ...) - let startIdx = 1; - let arrayId = selfRefId; // Use self-reference ID from link.id if present - - // Check for old format with obj_id as second element - if (!arrayId && link.values.length > 1) { - const second = link.values[1]; - if (second && second.id && second.id.startsWith('obj_')) { - arrayId = second.id; - startIdx = 2; - } - } + for (let i = startIdx; i < link.values.length; i++) { + resultArray.push(this._decodeLink(link.values[i])); + } + return resultArray; + } - const resultArray = []; - if (arrayId) { - this._decodeMemo.set(arrayId, resultArray); - } + /** + * Decode an encoded object. + * @param {Link} link - Link object to decode + * @param {(string|null)} selfRefId - Self-reference id of the link, if any + * @returns {object} Decoded object + */ + _decodeObject(link, selfRefId) { + const { id, startIdx } = ObjectCodec._collectionStart(link, selfRefId); - for (let i = startIdx; i < link.values.length; i++) { - const itemLink = link.values[i]; - const decodedItem = this._decodeLink(itemLink); - resultArray.push(decodedItem); - } - return resultArray; + const resultObject = {}; + // Memoise before decoding members so cycles resolve to this same object. + if (id) { + this._decodeMemo.set(id, resultObject); } - if (typeMarker === ObjectCodec.TYPE_OBJECT) { - // New format with self-reference: (obj_0: object (key val) ...) - // Old format (for backward compatibility): (object obj_id (key val) ...) - let startIdx = 1; - let objectId = selfRefId; // Use self-reference ID from link.id if present - - // Check for old format with obj_id as second element - if (!objectId && link.values.length > 1) { - const second = link.values[1]; - if (second && second.id && second.id.startsWith('obj_')) { - objectId = second.id; - startIdx = 2; - } + for (let i = startIdx; i < link.values.length; i++) { + const pairLink = link.values[i]; + if (pairLink.values && pairLink.values.length >= 2) { + const decodedKey = this._decodeLink(pairLink.values[0]); + const decodedValue = this._decodeLink(pairLink.values[1]); + resultObject[decodedKey] = decodedValue; } + } + return resultObject; + } - const resultObject = {}; - if (objectId) { - this._decodeMemo.set(objectId, resultObject); - } + /** + * Decode a Link into a JavaScript value. + * @param {Link} link - Link object to decode + * @returns {*} Decoded JavaScript value + */ + _decodeLink(link) { + // Check if this is a direct reference to a previously decoded object + // Direct references have an id but no values, or the id refers to an existing object + if (link.id && this._decodeMemo.has(link.id)) { + return this._decodeMemo.get(link.id); + } - for (let i = startIdx; i < link.values.length; i++) { - const pairLink = link.values[i]; - if (pairLink.values && pairLink.values.length >= 2) { - const keyLink = pairLink.values[0]; - const valueLink = pairLink.values[1]; + // Empty link - this might be a simple id, reference, or empty collection + if (!link.values || link.values.length === 0) { + return this._decodeEmptyLink(link); + } - const decodedKey = this._decodeLink(keyLink); - const decodedValue = this._decodeLink(valueLink); + // Check if this link has a self-reference ID (format: obj_0: type ...) + const selfRefId = link.id && link.id.startsWith('obj_') ? link.id : null; - resultObject[decodedKey] = decodedValue; - } - } - return resultObject; + // Get the type marker from the first value + const firstValue = link.values[0]; + if (!firstValue || !firstValue.id) { + // Not a type marker we recognize + return null; } + const typeMarker = firstValue.id; - // Unknown type marker - throw new Error(`Unknown type marker: ${typeMarker}`); + if (typeMarker === ObjectCodec.TYPE_ARRAY) { + return this._decodeArray(link, selfRefId); + } + if (typeMarker === ObjectCodec.TYPE_OBJECT) { + return this._decodeObject(link, selfRefId); + } + return ObjectCodec._decodeScalar(typeMarker, link); } } From 9149a3fa8437f256b6e4b82784d739c5efa585ac Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:17:19 +0000 Subject: [PATCH 13/29] ci: harden workflow permissions, timeouts and concurrency Applies best practices #7/#10 from the org CI/CD guide to all four language pipelines: - top-level 'permissions: contents: read'; release jobs keep their own elevated block, so nothing else gets a writable token by default; - 'timeout-minutes' on every job, so a hung runner fails instead of burning the 6h default; - job-level concurrency replaces the workflow-level cancellable group. A workflow-level 'cancel-in-progress: true' could cancel a release that had already started pushing a tag or a package. Read-only checks now cancel per job (matrix entries stay independent); every writer shares the repo-scoped 'main-writer-${{ github.repository }}' group with 'cancel-in-progress: false' and 'queue: max', so releases across the four languages queue instead of racing for the same branch; - 'always() && !cancelled()' collapses to '!cancelled()' (a bare always() keeps downstream work running after cancellation), with the stale comments updated; - 'github.head_ref' is passed through an environment variable instead of being interpolated into a shell script (actionlint 'expression' rule). --- .github/workflows/csharp.yml | 49 ++++++++++++++++-- .github/workflows/js.yml | 95 +++++++++++++++++++++++++++------- .github/workflows/python.yml | 43 ++++++++++++++-- .github/workflows/rust.yml | 99 +++++++++++++++++++++++++++++------- 4 files changed, 241 insertions(+), 45 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index c0bcf57..5252587 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -27,9 +27,10 @@ on: required: false type: string -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Least privilege by default (best practice #10 / template parity): individual +# release jobs raise this to `contents: write` where they need it. +permissions: + contents: read env: DOTNET_NOLOGO: true @@ -39,6 +40,11 @@ env: jobs: # Linting and formatting lint: + timeout-minutes: 15 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest steps: @@ -63,6 +69,11 @@ jobs: # Test matrix: .NET on multiple OS test: + timeout-minutes: 30 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: true name: Test (.NET on ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -102,6 +113,11 @@ jobs: # Build NuGet package build: + timeout-minutes: 20 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: true name: Build Package runs-on: ubuntu-latest needs: [lint, test] @@ -133,6 +149,11 @@ jobs: # Check for changeset in PRs changeset-check: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check + cancel-in-progress: true name: Changeset Check runs-on: ubuntu-latest if: github.event_name == 'pull_request' @@ -148,9 +169,13 @@ jobs: - name: Check for changeset working-directory: ./csharp + env: + # Passed through the environment: a branch name is attacker-controlled + # and must never be interpolated straight into a shell script. + HEAD_REF: ${{ github.head_ref }} run: | # Skip changeset check for automated release PRs - if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-manual-release-"* ]]; then + if [[ "$HEAD_REF" == "changeset-release/"* ]] || [[ "$HEAD_REF" == "changeset-manual-release-"* ]]; then echo "Skipping changeset check for automated release PR" exit 0 fi @@ -180,6 +205,14 @@ jobs: # Automatic release on push to main (if version changed) auto-release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Auto Release needs: [lint, test, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -314,6 +347,14 @@ jobs: # Manual release via workflow_dispatch manual-release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Manual Release needs: [lint, test, build] if: github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index cd34921..0ff2bff 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -35,13 +35,19 @@ on: required: false type: string -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Least privilege by default (best practice #10 / template parity): individual +# release jobs raise this to `contents: write` where they need it. +permissions: + contents: read jobs: # === DETECT CHANGES - determines which jobs should run === detect-changes: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: true name: Detect Changes runs-on: ubuntu-latest if: github.event_name != 'workflow_dispatch' @@ -69,6 +75,11 @@ jobs: # === VERSION CHANGE CHECK === # Prohibit manual version changes in package.json - versions should only be changed by CI/CD version-check: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: true name: Check for Manual Version Changes runs-on: ubuntu-latest if: github.event_name == 'pull_request' @@ -87,6 +98,11 @@ jobs: # === CHANGESET CHECK - only runs on PRs with code changes === # Docs-only PRs (./docs folder, markdown files) don't require changesets changeset-check: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changeset-check + cancel-in-progress: true name: Check for Changesets runs-on: ubuntu-latest needs: [detect-changes] @@ -111,9 +127,12 @@ jobs: GITHUB_BASE_REF: ${{ github.base_ref }} GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # Passed through the environment: a branch name is attacker-controlled + # and must never be interpolated straight into a shell script. + HEAD_REF: ${{ github.head_ref }} run: | # Skip changeset check for automated version PRs - if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then + if [[ "$HEAD_REF" == "changeset-release/"* ]]; then echo "Skipping changeset check for automated release PR" exit 0 fi @@ -124,19 +143,26 @@ jobs: # === LINT AND FORMAT CHECK === # Lint runs independently of changeset-check - it's a fast check that should always run lint: + timeout-minutes: 15 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest needs: [detect-changes] - if: | - always() && !cancelled() && ( - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.mjs-changed == 'true' || - needs.detect-changes.outputs.js-changed == 'true' || - needs.detect-changes.outputs.docs-changed == 'true' || - needs.detect-changes.outputs.package-changed == 'true' || - needs.detect-changes.outputs.workflow-changed == 'true' + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.js-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.package-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' ) + }} steps: - uses: actions/checkout@v7 @@ -163,11 +189,16 @@ jobs: # Test matrix: Node.js on multiple OS test: + timeout-minutes: 30 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: true name: Test (Node.js on ${{ matrix.os }}) runs-on: ${{ matrix.os }} needs: [detect-changes, changeset-check] # Run if: push event, OR changeset-check succeeded, OR changeset-check was skipped (docs-only PR) - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changeset-check.result == 'success' || needs.changeset-check.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -194,11 +225,19 @@ jobs: # Release - only runs on main after tests pass (for push events) release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Release needs: [lint, test] - # Use always() to ensure this job runs even if changeset-check was skipped + # Use !cancelled() so this job still runs when changeset-check was skipped # This is needed because lint/test jobs have a transitive dependency on changeset-check - if: always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' }} runs-on: ubuntu-latest # Permissions required for npm OIDC trusted publishing permissions: @@ -265,15 +304,25 @@ jobs: # Manual Instant Release - triggered via workflow_dispatch with instant mode instant-release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Instant Release needs: [lint, test] - # Note: always() is required to evaluate the condition when dependencies use always() - if: | - always() && !cancelled() && + # Note: !cancelled() is required to evaluate the condition when dependencies may be skipped + if: >- + ${{ + !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.lint.result == 'success' && needs.test.result == 'success' + }} runs-on: ubuntu-latest # Permissions required for npm OIDC trusted publishing permissions: @@ -327,6 +376,14 @@ jobs: # Manual Changeset PR - creates a pull request with the changeset for review changeset-pr: + timeout-minutes: 20 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Create Changeset PR if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' runs-on: ubuntu-latest diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 58baa04..b3b5be4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -27,9 +27,10 @@ on: required: false type: string -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Least privilege by default (best practice #10 / template parity): individual +# release jobs raise this to `contents: write` where they need it. +permissions: + contents: read jobs: # REQUIRED CI CHECKS - All must pass before release @@ -37,6 +38,11 @@ jobs: # Linting and formatting lint: + timeout-minutes: 15 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest steps: @@ -71,6 +77,11 @@ jobs: # Test on latest Python version only test: + timeout-minutes: 30 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test + cancel-in-progress: true name: Test (Python 3.13) runs-on: ubuntu-latest steps: @@ -100,6 +111,11 @@ jobs: # Build package - only runs if lint and test pass build: + timeout-minutes: 20 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: true name: Build Package runs-on: ubuntu-latest needs: [lint, test] @@ -132,6 +148,11 @@ jobs: # Check for changelog fragments in PRs (similar to changesets check) changelog: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: true name: Changelog Fragment Check runs-on: ubuntu-latest if: github.event_name == 'pull_request' @@ -181,6 +202,14 @@ jobs: # Automatic release on push to main (if version changed) auto-release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Auto Release needs: [lint, test, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -312,6 +341,14 @@ jobs: # Manual release via workflow_dispatch - only after CI passes manual-release: + timeout-minutes: 45 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Manual Release needs: [lint, test, build] if: github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index cb80b2f..4e747b0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -35,9 +35,10 @@ on: required: false type: string -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Least privilege by default (best practice #10 / template parity): individual +# release jobs raise this to `contents: write` where they need it. +permissions: + contents: read env: CARGO_TERM_COLOR: always @@ -46,6 +47,11 @@ env: jobs: # === DETECT CHANGES - determines which jobs should run === detect-changes: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: true name: Detect Changes runs-on: ubuntu-latest if: github.event_name != 'workflow_dispatch' @@ -78,6 +84,11 @@ jobs: # === CHANGELOG CHECK - only runs on PRs with code changes === # Docs-only PRs (./docs folder, markdown files) don't require changelog fragments changelog: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-changelog + cancel-in-progress: true name: Changelog Fragment Check runs-on: ubuntu-latest needs: [detect-changes] @@ -101,6 +112,11 @@ jobs: # === VERSION CHECK - prevents manual version modification in PRs === # This ensures versions are only modified by the automated release pipeline version-check: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-version-check + cancel-in-progress: true name: Version Modification Check runs-on: ubuntu-latest if: github.event_name == 'pull_request' @@ -125,20 +141,27 @@ jobs: # === LINT AND FORMAT CHECK === # Lint runs independently of changelog check - it's a fast check that should always run lint: + timeout-minutes: 20 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-lint + cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest needs: [detect-changes] - # Note: always() is required because detect-changes is skipped on workflow_dispatch - if: | - always() && !cancelled() && ( - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - needs.detect-changes.outputs.rs-changed == 'true' || - needs.detect-changes.outputs.toml-changed == 'true' || - needs.detect-changes.outputs.mjs-changed == 'true' || - needs.detect-changes.outputs.docs-changed == 'true' || - needs.detect-changes.outputs.workflow-changed == 'true' + # Note: !cancelled() is required because detect-changes is skipped on workflow_dispatch + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.rs-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' ) + }} steps: - uses: actions/checkout@v7 @@ -182,11 +205,16 @@ jobs: # === TEST === # Test runs independently of changelog check test: + timeout-minutes: 45 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} + cancel-in-progress: true name: Test (Rust on ${{ matrix.os }}) runs-on: ${{ matrix.os }} needs: [detect-changes, changelog] # Run if: push event, OR changelog succeeded, OR changelog was skipped (docs-only PR) - if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') + if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -223,10 +251,15 @@ jobs: # === BUILD === # Build package - only runs if lint and test pass build: + timeout-minutes: 30 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-build + cancel-in-progress: true name: Build Package runs-on: ubuntu-latest needs: [lint, test] - if: always() && !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' + if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v7 @@ -255,13 +288,23 @@ jobs: # === AUTO RELEASE === # Automatic release on push to main using changelog fragments auto-release: + timeout-minutes: 60 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Auto Release needs: [lint, test, build] - if: | - always() && !cancelled() && + if: >- + ${{ + !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.build.result == 'success' + }} runs-on: ubuntu-latest permissions: contents: write @@ -392,13 +435,23 @@ jobs: # === MANUAL INSTANT RELEASE === # Manual release via workflow_dispatch - only after CI passes manual-release: + timeout-minutes: 60 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Instant Release needs: [lint, test, build] - if: | - always() && !cancelled() && + if: >- + ${{ + !cancelled() && github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' && needs.build.result == 'success' + }} runs-on: ubuntu-latest permissions: contents: write @@ -503,6 +556,14 @@ jobs: # === MANUAL CHANGELOG PR === changelog-pr: + timeout-minutes: 20 + # Writer: every job in the repository that pushes commits, tags or + # packages shares this group, so releases queue instead of racing, and a + # started release is never cancelled mid-publish. + concurrency: + group: main-writer-${{ github.repository }} + cancel-in-progress: false + queue: max name: Create Changelog PR if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changelog-pr' runs-on: ubuntu-latest From 1d8dc18fffca36858fed0d23ec619f741aab82dc Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:17:43 +0000 Subject: [PATCH 14/29] ci: lint every workflow with actionlint A malformed workflow file is a silent false negative: GitHub refuses to run it and attaches no check, so the pull request still looks green while a pipeline has stopped running. actionlint parses all six workflows on every PR and also flags shell injection through untrusted contexts. --- .github/workflows/scripts.yml | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml index a3734bd..ff8ce15 100644 --- a/.github/workflows/scripts.yml +++ b/.github/workflows/scripts.yml @@ -22,16 +22,15 @@ on: permissions: contents: read -# A read-only check, so superseded runs are safe to cancel. -concurrency: - group: check-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: test: name: Test Shared Scripts runs-on: ubuntu-latest timeout-minutes: 10 + # Read-only checks, so superseded runs are safe to cancel. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-test + cancel-in-progress: true steps: - uses: actions/checkout@v7 @@ -42,3 +41,28 @@ jobs: - name: Run shared script tests run: node --test scripts/*.test.mjs + + actionlint: + name: Lint Workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-actionlint + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + + # A malformed workflow file is rejected by GitHub at dispatch time with no + # check attached to the pull request, i.e. a silent false negative: the PR + # looks green while a pipeline no longer runs at all. actionlint parses + # every workflow and also catches shell-injection and stale-context bugs. + - name: Run actionlint + env: + ACTIONLINT_VERSION: 1.7.7 + run: | + curl -sSfL \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar xz actionlint + # `queue:` is valid workflow syntax (documented under `concurrency`) + # but is newer than actionlint's bundled schema, so it is filtered out. + ./actionlint -ignore 'unexpected key "queue" for "concurrency" section' From d3738cfd9c76f057a334f8d2c7e0e47f8bd7aaee Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:24:54 +0000 Subject: [PATCH 15/29] ci: add security scanning and a broken-link check Two whole classes of check were missing, so every pull request was green on questions nobody was asking (best practices #11 and #12): - security.yml: CodeQL over javascript-typescript, python, csharp and the workflow files themselves; dependency-review on pull requests; and a lockfile audit per ecosystem (npm, cargo-audit, pip-audit). No paths: filter and a weekly schedule, so newly published advisories surface without a code change. - links.yml: lychee over every Markdown file, excluding docs/case-studies and dev/log, which quote other repositories' issues and runs as evidence. All five audits were run locally against this tree first: npm audit, cargo audit and pip-audit report no vulnerabilities. --- .github/workflows/links.yml | 59 +++++++++++++ .github/workflows/security.yml | 151 +++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 .github/workflows/links.yml create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000..fe665c7 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,59 @@ +name: Broken Link Checker + +# Best practice #12: documentation is validated in CI like code. Before issue +# #41 nothing checked the links in the README files that every registry page +# renders, so a dead link shipped to npm, PyPI, crates.io and NuGet unnoticed. + +on: + push: + branches: + - main + paths: + - '**.md' + - '.github/workflows/links.yml' + - '.lycheeignore' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.md' + - '.github/workflows/links.yml' + - '.lycheeignore' + workflow_dispatch: + +# Least-privilege default; this workflow only reads the repository. +permissions: + contents: read + +jobs: + link-checker: + name: Check Links + runs-on: ubuntu-latest + # Typical run is well under a minute with the lychee cache; the timeout + # stops a slow external host from hanging the workflow. + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-link-checker + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + + - name: Check links with lychee + uses: lycheeverse/lychee-action@v2 + with: + # `docs/case-studies` and `dev/log` are research records that quote + # issues, runs and files belonging to other repositories; their links + # are evidence, not navigation, and must not gate this repository. + args: >- + --verbose + --no-progress + --cache + --max-cache-age 1d + --max-retries 3 + --timeout 30 + --exclude-path docs/case-studies + --exclude-path dev/log + './**/*.md' + fail: true + jobSummary: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3bc61a8 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,151 @@ +name: Security + +# Issue #41 asked for the false negatives in this pipeline. The largest one was +# an absence: nothing scanned this repository for vulnerable dependencies or +# insecure code, so every pull request was green by default on those questions. +# This workflow mirrors the `security.yml` of the org's language pipeline +# templates, widened to the four languages that live here. +# +# There is deliberately no `paths:` filter: a security scan that can be skipped +# by touching the right file is not a security scan. + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + schedule: + # Weekly, so newly published advisories are found without a code change. + - cron: '0 6 * * 1' + workflow_dispatch: + +# Least-privilege default; jobs escalate individually when needed. +permissions: + contents: read + +jobs: + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-codeql-${{ matrix.language }} + cancel-in-progress: true + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + # `actions` scans the workflow files themselves, which is what would + # have caught the `github.head_ref` interpolation fixed in this PR. + # Rust has no stable CodeQL pack; `cargo clippy -D warnings` in + # rust.yml is its static analysis. + language: [javascript-typescript, python, csharp, actions] + steps: + - uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Setup .NET + if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Analyze + uses: github/codeql-action/analyze@v4 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + + - name: Review dependency changes + uses: actions/dependency-review-action@v5 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + npm-audit: + name: Audit npm lock + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-npm-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + + # `--package-lock-only` audits the committed lockfile rather than an + # install resolved at run time, so the result matches what reviewers see. + - name: Audit the committed lockfile + working-directory: ./js + run: npm audit --package-lock-only --audit-level=high + + cargo-audit: + name: Audit Cargo lock + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-cargo-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Audit Rust dependencies + working-directory: ./rust + run: cargo audit + + pip-audit: + name: Audit Python dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-pip-audit + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.13' + + - name: Install pip-audit + run: python -m pip install --upgrade pip pip-audit + + # `--skip-editable` leaves out this repository's own editable install, + # which has no PyPI record to audit; `--strict` cannot be used with it + # because the skip itself is reported as an error. + - name: Audit installed dependencies + working-directory: ./python + run: | + python -m pip install -e ".[dev]" + pip-audit --skip-editable --progress-spinner off From d28060bc99823198ddb8a2102e76b11ef8c7f677 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 20 Aug 2026 08:30:27 +0000 Subject: [PATCH 16/29] ci(links): exclude node_modules and bot-blocked npmjs.com from link check A local run of the new lychee gate failed on links this repository cannot fix: the './**/*.md' glob picked up third-party READMEs under js/node_modules, and www.npmjs.com answers every automated request with 403 (verified with and without a browser User-Agent), so the package badges in README.md and js/README.md were reported broken. Adds .lycheeignore for the npmjs.com host and --exclude-path js/node_modules. Local run is now 107 OK / 0 errors. --- .github/workflows/links.yml | 3 +++ .lycheeignore | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 .lycheeignore diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index fe665c7..04f39c6 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -43,6 +43,8 @@ jobs: # `docs/case-studies` and `dev/log` are research records that quote # issues, runs and files belonging to other repositories; their links # are evidence, not navigation, and must not gate this repository. + # `js/node_modules` only exists in local runs, but excluding it keeps + # the same command reproducible outside CI. args: >- --verbose --no-progress @@ -52,6 +54,7 @@ jobs: --timeout 30 --exclude-path docs/case-studies --exclude-path dev/log + --exclude-path js/node_modules './**/*.md' fail: true jobSummary: true diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..d8c11f0 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,9 @@ +# Hosts that answer automated requests with a rejection regardless of the link +# being valid. Excluding them keeps the link check honest: it fails only on +# links this repository can actually fix. + +# npmjs.com is fronted by a bot filter that returns 403 to every non-browser +# client (verified locally and from CI runners), so the package badges and +# links in README.md and js/README.md are permanently "broken" to lychee even +# though they resolve fine in a browser. +https://www\.npmjs\.com/.* From cee793ce307857d8d8e8954d8f0d123c81e24242 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:33:01 +0000 Subject: [PATCH 17/29] ci: validate the actual merge result on pull requests (best practice #7) The refs/pull/N/merge preview GitHub checks out is built when the PR is opened or synced; once the base branch moves it is stale, so lint, test and build validated code that is not what will land. Adds scripts/simulate-fresh-merge.sh (with an off-by-default VERBOSE mode that lists the commits the base is ahead by) and calls it from the read-only check jobs of all four language workflows, with fetch-depth: 0 so the base branch history is available. Verified: actionlint clean, all workflows parse, script runs locally. --- .github/workflows/csharp.yml | 57 ++++++++++++++++++++++++++++ .github/workflows/js.yml | 42 +++++++++++++++++++++ .github/workflows/python.yml | 57 ++++++++++++++++++++++++++++ .github/workflows/rust.yml | 57 ++++++++++++++++++++++++++++ scripts/simulate-fresh-merge.sh | 66 +++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100755 scripts/simulate-fresh-merge.sh diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 5252587..5eda323 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -49,6 +49,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup .NET uses: actions/setup-dotnet@v6 @@ -82,6 +97,21 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup .NET uses: actions/setup-dotnet@v6 @@ -123,6 +153,21 @@ jobs: needs: [lint, test] steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup .NET uses: actions/setup-dotnet@v6 @@ -162,6 +207,18 @@ jobs: with: fetch-depth: 0 + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh + - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 0ff2bff..7d9338c 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -112,6 +112,18 @@ jobs: with: fetch-depth: 0 + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh + - name: Setup Node.js uses: actions/setup-node@v7 with: @@ -165,6 +177,21 @@ jobs: }} steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup Node.js uses: actions/setup-node@v7 @@ -205,6 +232,21 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b3b5be4..a319b91 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -47,6 +47,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup Python uses: actions/setup-python@v7 @@ -86,6 +101,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup Python uses: actions/setup-python@v7 @@ -121,6 +151,21 @@ jobs: needs: [lint, test] steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Setup Python uses: actions/setup-python@v7 @@ -161,6 +206,18 @@ jobs: with: fetch-depth: 0 + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh + - name: Set up Python uses: actions/setup-python@v7 with: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4e747b0..b3405e8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -98,6 +98,18 @@ jobs: with: fetch-depth: 0 + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh + - name: Setup Node.js uses: actions/setup-node@v7 with: @@ -164,6 +176,21 @@ jobs: }} steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -221,6 +248,21 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -262,6 +304,21 @@ jobs: if: ${{ !cancelled() && needs.lint.result == 'success' && needs.test.result == 'success' }} steps: - uses: actions/checkout@v7 + with: + # Full history: the merge simulation below needs the base branch. + fetch-depth: 0 + + # Best practice #7: the `refs/pull/N/merge` preview goes stale as soon as + # the base branch moves, so checks would validate code that is not what + # will actually land. Merge the latest base branch first. + - name: Simulate fresh merge with base branch (PR only) + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + # Explicit shell: the matrix includes Windows runners, where the + # default shell is PowerShell. + shell: bash + run: bash scripts/simulate-fresh-merge.sh - name: Set up Rust uses: dtolnay/rust-toolchain@stable diff --git a/scripts/simulate-fresh-merge.sh b/scripts/simulate-fresh-merge.sh new file mode 100755 index 0000000..87b3824 --- /dev/null +++ b/scripts/simulate-fresh-merge.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# simulate-fresh-merge.sh +# +# Simulates a fresh merge of the current pull-request branch with the latest +# base branch, so CI validates the state that will actually land on the base +# branch instead of a stale merge preview. +# +# GitHub builds `refs/pull/N/merge` when a pull request is opened or synced. +# If the base branch moves afterwards, that preview is out of date: checks can +# pass against code that no longer merges cleanly, or fail against a conflict +# that has already been resolved upstream. This is CI/CD best practice #7, +# "Validate the Actual Merge Result". +# +# Usage: +# BASE_REF=main bash scripts/simulate-fresh-merge.sh +# +# Environment variables: +# BASE_REF The base branch to merge with (for example "main"). Required. +# VERBOSE Set to "1" to echo the commits that the base branch is ahead by. +# +# Exit code 0 = merge succeeded or was not needed; 1 = merge conflict. + +set -euo pipefail + +if [ -z "${BASE_REF:-}" ]; then + echo "::error::BASE_REF is not set; cannot simulate a merge." + exit 1 +fi + +echo "=== Synchronizing with the latest $BASE_REF ===" + +# A local identity is required for `git merge` to record a merge commit. +git config user.email "github-actions[bot]@users.noreply.github.com" +git config user.name "github-actions[bot]" + +git fetch origin "$BASE_REF" + +echo "Current checkout (merge preview): $(git rev-parse HEAD)" +echo "Latest base branch ($BASE_REF): $(git rev-parse "origin/$BASE_REF")" + +BEHIND_COUNT=$(git rev-list --count "HEAD..origin/$BASE_REF") + +if [ "$BEHIND_COUNT" -eq 0 ]; then + echo "Merge preview is up to date with $BASE_REF; no simulation needed." + exit 0 +fi + +echo "Base branch has $BEHIND_COUNT new commit(s) since this run was queued." + +# Off by default: the commit list is only useful while debugging a surprising +# merge result, and it makes every ordinary run noisier. +if [ "${VERBOSE:-0}" = "1" ]; then + echo "--- commits only on origin/$BASE_REF ---" + git log --oneline "HEAD..origin/$BASE_REF" + echo "---------------------------------------" +fi + +echo "Simulating a fresh merge to validate the real merge result..." + +if git merge "origin/$BASE_REF" --no-edit; then + echo "Fresh merge simulation successful; checks run against the merged state." +else + echo "::error::Merge conflict detected. This pull request must be updated \ +with $BASE_REF before it can be merged." + exit 1 +fi From 1c3b6e56e5004ba04f86e565c6fff8591f6bccf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:34:48 +0000 Subject: [PATCH 18/29] ci(csharp): enforce the file-size limit and run the helper script tests csharp/scripts/*.test.mjs existed but no workflow ever executed them, so a broken release helper would only have surfaced during a real release. The C# sources also had no line-count ceiling, unlike Rust (scripts/check-file-size.mjs), Python (scripts/check_file_size.py) and JavaScript (ESLint max-lines). Ports the template's check-file-size.mjs with the repository-wide 1500-line limit (best practice #2 range, matching the ESLint rule) plus its test suite converted to node:test, and adds both steps to the C# lint job. Verified locally: 10/10 script tests pass, file-size check exits 0. --- .github/workflows/csharp.yml | 35 ++- .github/workflows/js.yml | 12 +- .github/workflows/python.yml | 16 +- .github/workflows/rust.yml | 16 +- csharp/scripts/check-file-size.mjs | 269 ++++++++++++++++++++++++ csharp/scripts/check-file-size.test.mjs | 115 ++++++++++ 6 files changed, 433 insertions(+), 30 deletions(-) create mode 100644 csharp/scripts/check-file-size.mjs create mode 100644 csharp/scripts/check-file-size.test.mjs diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 5eda323..894002b 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -60,8 +60,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -70,6 +70,12 @@ jobs: with: dotnet-version: '8.0.x' + # Node powers the shared CI helper scripts under csharp/scripts. + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + - name: Restore dependencies working-directory: ./csharp run: dotnet restore @@ -82,6 +88,19 @@ jobs: working-directory: ./csharp run: dotnet build --configuration Release --no-restore /warnaserror + # Best practice #2: the same 1500-line ceiling the JavaScript ESLint + # config enforces, applied to C# sources. + - name: Check file size limit + working-directory: ./csharp + run: node scripts/check-file-size.mjs + + # The release helpers under csharp/scripts are executed by the release + # jobs but their unit tests never ran in CI before issue #41, so a broken + # helper would only have surfaced during an actual release. + - name: Run CI script tests + working-directory: ./csharp + run: node --test scripts/*.test.mjs + # Test matrix: .NET on multiple OS test: timeout-minutes: 30 @@ -108,8 +127,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -164,8 +183,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -214,8 +233,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 7d9338c..bf788b3 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -119,8 +119,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -188,8 +188,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -243,8 +243,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a319b91..1dabe95 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -58,8 +58,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -112,8 +112,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -162,8 +162,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -213,8 +213,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b3405e8..4f698f2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -105,8 +105,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -187,8 +187,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -259,8 +259,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh @@ -315,8 +315,8 @@ jobs: if: github.event_name == 'pull_request' env: BASE_REF: ${{ github.base_ref }} - # Explicit shell: the matrix includes Windows runners, where the - # default shell is PowerShell. + # Explicit shell: some of these jobs run on Windows runners, where + # the default shell is PowerShell. shell: bash run: bash scripts/simulate-fresh-merge.sh diff --git a/csharp/scripts/check-file-size.mjs b/csharp/scripts/check-file-size.mjs new file mode 100644 index 0000000..eec3ff7 --- /dev/null +++ b/csharp/scripts/check-file-size.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node + +/** + * Check C# files for maximum and warning line-count thresholds. + * Exits with error code 1 if any files exceed the hard limit. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { extname, join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// 1500 is the upper end of the 1000-1500 range in CI/CD best practice #2 and +// matches the `max-lines` ESLint rule the JavaScript implementation enforces, +// so the same limit applies to every language in this repository. +export const MAX_LINES = 1500; +export const WARN_LINES = 1200; +export const FILE_EXTENSIONS = ['.cs']; +const EXCLUDE_PATTERNS = ['bin', 'obj', '.git', 'node_modules', 'artifacts']; + +/** + * Check if a path should be excluded + * @param {string} path + * @returns {boolean} + */ +export function shouldExclude(path) { + return EXCLUDE_PATTERNS.some((pattern) => path.includes(pattern)); +} + +/** + * Recursively find all C# files in a directory + * @param {string} directory + * @returns {string[]} + */ +export function findCSharpFiles(directory) { + const files = []; + + function walkDir(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (shouldExclude(fullPath)) { + continue; + } + + if (entry.isDirectory()) { + walkDir(fullPath); + } else if (entry.isFile() && FILE_EXTENSIONS.includes(extname(entry.name))) { + files.push(fullPath); + } + } + } + + walkDir(directory); + return files.sort(); +} + +/** + * Count lines in a file + * @param {string} filePath + * @returns {number} + */ +export function countLines(filePath) { + const content = readFileSync(filePath, 'utf-8'); + if (content.length === 0) { + return 0; + } + + const lineCount = content.split(/\r\n|\r|\n/u).length; + return content.endsWith('\n') || content.endsWith('\r') + ? lineCount - 1 + : lineCount; +} + +/** + * Classify a line count against the configured thresholds. + * @param {number} lineCount + * @returns {'within-limit' | 'warning' | 'violation'} + */ +export function classifyLineCount(lineCount) { + if (lineCount > MAX_LINES) { + return 'violation'; + } + if (lineCount > WARN_LINES) { + return 'warning'; + } + return 'within-limit'; +} + +/** + * Convert a path to a stable repository-relative value for output. + * @param {string} cwd + * @param {string} file + * @returns {string} + */ +function relativeFilePath(cwd, file) { + return relative(cwd, file).replaceAll('\\', '/'); +} + +/** + * Check a directory and collect warning-band and hard-limit findings. + * @param {string} cwd + * @returns {{ files: number, warnings: Array<{ file: string, lines: number }>, violations: Array<{ file: string, lines: number }> }} + */ +export function checkDirectory(cwd = process.cwd()) { + const files = findCSharpFiles(cwd); + const result = { + files: files.length, + warnings: [], + violations: [], + }; + + for (const file of files) { + const lineCount = countLines(file); + const finding = { + file: relativeFilePath(cwd, file), + lines: lineCount, + }; + + switch (classifyLineCount(lineCount)) { + case 'violation': + result.violations.push(finding); + break; + case 'warning': + result.warnings.push(finding); + break; + case 'within-limit': + break; + default: + throw new Error(`Unknown line-count classification for ${file}`); + } + } + + return result; +} + +/** + * Escape a GitHub Actions annotation property value. + * @param {string} value + * @returns {string} + */ +export function escapeAnnotationProperty(value) { + return value + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A') + .replaceAll(':', '%3A') + .replaceAll(',', '%2C'); +} + +/** + * Escape a GitHub Actions annotation message value. + * @param {string} value + * @returns {string} + */ +export function escapeAnnotationMessage(value) { + return value + .replaceAll('%', '%25') + .replaceAll('\r', '%0D') + .replaceAll('\n', '%0A'); +} + +/** + * Build a GitHub Actions warning annotation for a warning-band finding. + * @param {{ file: string, lines: number }} finding + * @returns {string} + */ +export function warningAnnotation(finding) { + const message = + `File has ${finding.lines} lines (approaching limit of ${MAX_LINES}). ` + + `Consider extracting code to keep at or below ${WARN_LINES} lines and ` + + 'prevent concurrent PR merge limit violations.'; + + return `::warning file=${escapeAnnotationProperty(finding.file)}::${escapeAnnotationMessage(message)}`; +} + +/** + * Return the process exit code for a check result. + * @param {{ violations: Array<{ file: string, lines: number }> }} result + * @returns {number} + */ +export function exitCodeForResult(result) { + return result.violations.length === 0 ? 0 : 1; +} + +/** + * Print warning-band findings without failing the check. + * @param {Array<{ file: string, lines: number }>} warnings + */ +function printWarnings(warnings) { + if (warnings.length === 0) { + return; + } + + for (const warning of warnings) { + console.log(warningAnnotation(warning)); + console.log( + `WARNING: ${warning.file} has ${warning.lines} lines (approaching limit of ${MAX_LINES}, warning threshold: ${WARN_LINES})` + ); + } + + console.log(); + console.log( + `The following files are approaching the ${MAX_LINES} line limit (>${WARN_LINES} lines):` + ); + for (const warning of warnings) { + console.log(` ${warning.file}`); + } + console.log( + '\nConsider extracting code to prevent concurrent PR merge limit violations.\n' + ); +} + +/** + * Print hard-limit violations. + * @param {Array<{ file: string, lines: number }>} violations + */ +function printViolations(violations) { + if (violations.length === 0) { + return; + } + + console.log('Found files exceeding the line limit:\n'); + for (const violation of violations) { + console.log( + ` ${violation.file}: ${violation.lines} lines (exceeds ${MAX_LINES})` + ); + } + console.log(`\nPlease refactor these files to be under ${MAX_LINES} lines\n`); +} + +/** + * Run the command-line file-size check. + * @param {string} cwd + * @returns {number} + */ +export function main(cwd = process.cwd()) { + console.log( + `\nChecking C# files for maximum ${MAX_LINES} lines (warning above ${WARN_LINES})...\n` + ); + + const result = checkDirectory(cwd); + + printWarnings(result.warnings); + + if (result.violations.length === 0) { + console.log(`Checked ${result.files} file(s) - all within the line limit\n`); + } else { + printViolations(result.violations); + } + + return exitCodeForResult(result); +} + +const entryPath = process.argv[1]; +const invokedDirectly = + typeof entryPath === 'string' && + entryPath.length > 0 && + import.meta.url === pathToFileURL(resolve(entryPath)).href; + +if (invokedDirectly) { + try { + process.exit(main()); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} diff --git a/csharp/scripts/check-file-size.test.mjs b/csharp/scripts/check-file-size.test.mjs new file mode 100644 index 0000000..940de1a --- /dev/null +++ b/csharp/scripts/check-file-size.test.mjs @@ -0,0 +1,115 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { + MAX_LINES, + WARN_LINES, + checkDirectory, + classifyLineCount, + exitCodeForResult, + main, + warningAnnotation, +} from './check-file-size.mjs'; + +function makeRepo() { + return mkdtempSync(path.join(tmpdir(), 'check-file-size-')); +} + +function writeCSharpFileWithLines(filePath, lineCount) { + const content = Array.from( + { length: lineCount }, + (_, index) => `// line ${index + 1}` + ).join('\n'); + writeFileSync(filePath, content); +} + +function captureConsoleLog(callback) { + const originalLog = console.log; + const lines = []; + console.log = (...args) => { + lines.push(args.join(' ')); + }; + + try { + return { result: callback(), output: lines.join('\n') }; + } finally { + console.log = originalLog; + } +} + +test('classifies the warning band without blocking', () => { + assert.equal(classifyLineCount(WARN_LINES), 'within-limit'); + assert.equal(classifyLineCount(WARN_LINES + 1), 'warning'); + assert.equal(classifyLineCount(MAX_LINES), 'warning'); + assert.equal( + exitCodeForResult({ + warnings: [{ file: 'src/near_limit.cs', lines: WARN_LINES + 1 }], + violations: [], + }), + 0 + ); +}); + +test('classifies hard limit violations as failures', () => { + assert.equal(classifyLineCount(MAX_LINES + 1), 'violation'); + assert.equal( + exitCodeForResult({ + warnings: [], + violations: [{ file: 'src/over_limit.cs', lines: MAX_LINES + 1 }], + }), + 1 + ); +}); + +test('reports warnings and violations separately', () => { + const repo = makeRepo(); + try { + const srcDir = path.join(repo, 'src'); + mkdirSync(srcDir, { recursive: true }); + writeCSharpFileWithLines(path.join(srcDir, 'near_limit.cs'), WARN_LINES + 1); + writeCSharpFileWithLines(path.join(srcDir, 'over_limit.cs'), MAX_LINES + 1); + writeCSharpFileWithLines(path.join(srcDir, 'small.cs'), WARN_LINES); + + const result = checkDirectory(repo); + + assert.equal(result.files, 3); + assert.deepEqual(result.warnings, [ + { file: 'src/near_limit.cs', lines: WARN_LINES + 1 }, + ]); + assert.deepEqual(result.violations, [ + { file: 'src/over_limit.cs', lines: MAX_LINES + 1 }, + ]); + } finally { + rmSync(repo, { force: true, recursive: true }); + } +}); + +test('warning annotation uses the GitHub Actions format', () => { + assert.equal( + warningAnnotation({ file: 'src/near_limit.cs', lines: WARN_LINES + 1 }), + `::warning file=src/near_limit.cs::File has ${WARN_LINES + 1} lines ` + + `(approaching limit of ${MAX_LINES}). Consider extracting code to keep ` + + `at or below ${WARN_LINES} lines and prevent concurrent PR merge limit ` + + 'violations.' + ); +}); + +test('main emits warning annotations without failing warning-only files', () => { + const repo = makeRepo(); + try { + const srcDir = path.join(repo, 'src'); + mkdirSync(srcDir, { recursive: true }); + writeCSharpFileWithLines(path.join(srcDir, 'near_limit.cs'), WARN_LINES + 1); + + const { result, output } = captureConsoleLog(() => main(repo)); + + assert.equal(result, 0); + assert.match(output, /::warning file=src\/near_limit\.cs::/); + assert.match(output, /Checked 1 file\(s\) - all within the line limit/); + } finally { + rmSync(repo, { force: true, recursive: true }); + } +}); From f1a50c99de55be8809a456962984459a0c9a50d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:35:51 +0000 Subject: [PATCH 19/29] ci(security): add committed-secret scanning (best practice #11) Nothing scanned this repository for credentials. Adds a gitleaks job that checks both the working tree and the full git history, using the pinned MIT-licensed CLI rather than gitleaks-action, which requires a paid licence for organisation-owned repositories. An unconfigured run reports 7 findings, all false positives: the generic-api-key rule matches the "key: " lines that actions/cache prints in the CI logs archived under docs/case-studies and dev/log. .gitleaks.toml allowlists those evidence directories with the reasoning recorded inline, so the scan stays actionable. Verified locally: 0 findings on the tree and across 146 commits. --- .github/workflows/security.yml | 37 ++++++++++++++++++++++++++++++++++ .gitleaks.toml | 22 ++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 .gitleaks.toml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3bc61a8..1738ca0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -149,3 +149,40 @@ jobs: run: | python -m pip install -e ".[dev]" pip-audit --skip-editable --progress-spinner off + + # === SECRETS DETECTION (best practice #11) === + # Nothing scanned this repository for committed credentials before issue #41. + secrets-scan: + name: Scan for Committed Secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-secrets-scan + cancel-in-progress: true + steps: + - uses: actions/checkout@v7 + with: + # Full history: a secret that was committed and later removed is + # still in the repository and still has to be rotated. + fetch-depth: 0 + + # The pinned CLI is used instead of gitleaks-action because the action + # requires a paid licence key for organisation-owned repositories, while + # the binary itself is MIT-licensed and free. + - name: Install gitleaks + env: + GITLEAKS_VERSION: 8.28.0 + run: | + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar xz gitleaks + ./gitleaks version + + # Scans the working tree and the full git history. Findings are redacted + # so a real secret is never echoed into the public log (best practice #11: + # "Never log environment variables or token values"). + - name: Scan working tree + run: ./gitleaks dir . --no-banner --redact --config .gitleaks.toml + + - name: Scan git history + run: ./gitleaks git . --no-banner --redact --config .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..4379936 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,22 @@ +# Gitleaks configuration for the secrets scan in .github/workflows/security.yml. +# +# Extends the bundled default rule set; the allowlist below removes known +# false positives so the scan stays actionable. Everything allowlisted here is +# archived evidence, never live code or configuration. + +[extend] +useDefault = true + +[[rules]] + id = "generic-api-key" + [rules.allowlist] + description = """ + Archived CI logs quote actions/cache output such as + "key: macOS-cargo-7c03a762fabedaf...", which is a content hash of + Cargo.lock, not a credential. The generic-api-key rule matches the + "key: " shape, so it flags every cached run we keep as evidence. + """ + paths = [ + '''docs/case-studies/.*''', + '''dev/log/.*''', + ] From b0496856a23753be91d9f8706bb292b5d8fd210a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:39:50 +0000 Subject: [PATCH 20/29] ci: make the Python and C# release-note checks able to fail Both checks were false negatives. python.yml and csharp.yml each carried an inline shell copy that printed "::warning::No changelog fragment found" / "::warning::No changeset found" and then exited 0, so a PR could change python/src or csharp/src with no release note and still show a green check. Both also counted every file in changelog.d/ and .changeset/, so a leftover fragment from an earlier unreleased PR satisfied the check for a new PR that added nothing. Both now decide from the PR diff and exit 1, matching the Rust and JavaScript implementations: - csharp/scripts/check-changeset.mjs (new, with unit tests) - python/scripts/validate_changeset.py (existed but no workflow ever ran it; extended with the diff-based requirement, with unit tests) Both use git diff --relative, without which the monorepo path prefix silently disables the check (issue #39), and both pass github.base_ref/head_ref through the environment instead of interpolating them into shell. Verified locally: 7/7 C# script tests, 9/9 new Python tests, 186 Python tests total, ruff and mypy clean, actionlint clean. --- .github/workflows/csharp.yml | 31 +---- .github/workflows/python.yml | 35 +---- csharp/scripts/check-changeset.mjs | 167 ++++++++++++++++++++++++ csharp/scripts/check-changeset.test.mjs | 57 ++++++++ python/scripts/validate_changeset.py | 159 ++++++++++++++++------ python/tests/test_validate_changeset.py | 86 ++++++++++++ 6 files changed, 436 insertions(+), 99 deletions(-) create mode 100644 csharp/scripts/check-changeset.mjs create mode 100644 csharp/scripts/check-changeset.test.mjs create mode 100644 python/tests/test_validate_changeset.py diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 894002b..e0ae18f 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -246,38 +246,11 @@ jobs: - name: Check for changeset working-directory: ./csharp env: + GITHUB_BASE_REF: ${{ github.base_ref }} # Passed through the environment: a branch name is attacker-controlled # and must never be interpolated straight into a shell script. HEAD_REF: ${{ github.head_ref }} - run: | - # Skip changeset check for automated release PRs - if [[ "$HEAD_REF" == "changeset-release/"* ]] || [[ "$HEAD_REF" == "changeset-manual-release-"* ]]; then - echo "Skipping changeset check for automated release PR" - exit 0 - fi - - # Get list of changeset files (excluding README.md and config.json) - CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" 2>/dev/null | wc -l) - - # Get changed files in PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - # Check if any source files changed (excluding docs and config) - SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^csharp/(src/|tests/|examples/)" | wc -l) - - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$CHANGESET_COUNT" -eq 0 ]; then - echo "::warning::No changeset found. Please add a changeset in csharp/.changeset/" - echo "" - echo "To create a changeset:" - echo " cd csharp/.changeset" - echo " Create a file: YYYYMMDD_HHMMSS_description.md" - echo "" - echo "See csharp/.changeset/README.md for more information." - # Note: This is a warning, not a failure, to allow flexibility - exit 0 - fi - - echo "✓ Changeset check passed" + run: node scripts/check-changeset.mjs # Automatic release on push to main (if version changed) auto-release: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 1dabe95..f84e31d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -223,37 +223,14 @@ jobs: with: python-version: "3.13" - - name: Install scriv - run: pip install "scriv[toml]" - - name: Check for changelog fragments working-directory: ./python - run: | - # Get list of fragment files (excluding README and template) - FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l) - - # Get changed files in PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - # Check if any source files changed (excluding docs and config) - SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^python/(src/|tests/|scripts/)" | wc -l) - - if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then - echo "::warning::No changelog fragment found. Please run 'scriv create' and document your changes." - echo "" - echo "To create a changelog fragment:" - echo " cd python" - echo " pip install 'scriv[toml]'" - echo " scriv create" - echo "" - echo "This is similar to adding a changeset in JavaScript projects." - echo "See python/changelog.d/README.md for more information." - # Note: This is a warning, not a failure, to allow flexibility - # Change 'exit 0' to 'exit 1' to make it required - exit 0 - fi - - echo "✓ Changelog check passed" + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + # Passed through the environment: a branch name is attacker-controlled + # and must never be interpolated straight into a shell script. + HEAD_REF: ${{ github.head_ref }} + run: python scripts/validate_changeset.py # RELEASE JOBS - Only run after all CI checks pass diff --git a/csharp/scripts/check-changeset.mjs b/csharp/scripts/check-changeset.mjs new file mode 100644 index 0000000..911c4ff --- /dev/null +++ b/csharp/scripts/check-changeset.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +/** + * Check that a changeset was added in the current PR. + * + * Replaces the inline shell check that used to live in .github/workflows/csharp.yml. + * That version had two defects, both found while working on issue #41: + * + * 1. It printed `::warning::No changeset found` and then `exit 0`, so the + * check could never fail. A PR could change csharp/src without any + * release note and still show a green "Changeset Check". + * 2. It counted every file in `.changeset/`, so a leftover fragment from an + * earlier, not-yet-released PR satisfied the check for a new PR that + * added nothing. This script looks at the PR diff instead. + * + * Mirrors rust/scripts/check-changelog-fragment.mjs. + * + * Usage: node scripts/check-changeset.mjs (from the `csharp` directory) + * + * Environment variables (set by GitHub Actions): + * - GITHUB_BASE_REF: base branch name for the PR (for example "main") + * - HEAD_REF: PR head branch; automated release branches are exempt + * + * Exit codes: + * - 0: check passed (changeset added, or no source changes) + * - 1: check failed (source changes without a changeset) + */ + +import { execSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * Branch prefixes used by the automated release jobs. Those PRs only bump + * versions and consume changesets, so requiring a new one would deadlock them. + * @type {string[]} + */ +export const RELEASE_BRANCH_PREFIXES = [ + 'changeset-release/', + 'changeset-manual-release-', +]; + +/** + * Check whether a branch belongs to an automated release PR. + * @param {string | undefined} headRef + * @returns {boolean} + */ +export function isReleaseBranch(headRef) { + if (!headRef) { + return false; + } + return RELEASE_BRANCH_PREFIXES.some((prefix) => headRef.startsWith(prefix)); +} + +/** + * Check whether a file is a source file that requires a changeset. + * Paths are relative to the `csharp` directory. + * @param {string} filePath + * @returns {boolean} + */ +export function isSourceFile(filePath) { + const sourcePatterns = [/^src\//, /^tests\//, /^examples\//, /^scripts\//, /\.csproj$/]; + return sourcePatterns.some((pattern) => pattern.test(filePath)); +} + +/** + * Check whether a file is a changeset fragment. + * `README.md` documents the directory and `config.json` configures it. + * @param {string} filePath + * @returns {boolean} + */ +export function isChangeset(filePath) { + return ( + filePath.startsWith('.changeset/') && + filePath.endsWith('.md') && + !filePath.endsWith('README.md') + ); +} + +/** + * Decide the outcome of the check from a list of changed files. + * @param {string[]} changedFiles + * @returns {{ ok: boolean, sourceFiles: string[], changesets: string[] }} + */ +export function evaluate(changedFiles) { + const sourceFiles = changedFiles.filter(isSourceFile); + const changesets = changedFiles.filter(isChangeset); + return { + ok: sourceFiles.length === 0 || changesets.length > 0, + sourceFiles, + changesets, + }; +} + +/** + * Get the list of files changed in the PR, relative to the `csharp` directory. + * + * `--relative` is essential in this monorepo: without it git prints + * repo-root-relative paths such as `csharp/src/...`, which match neither + * `^src/` nor `.changeset/`, silently disabling the check. See issue #39 for + * the same bug in the Rust script. + * @returns {string[]} + */ +export function getChangedFiles() { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + console.log(`Comparing against origin/${baseRef}...HEAD`); + + try { + const output = execSync( + `git diff --name-only --relative origin/${baseRef}...HEAD`, + { encoding: 'utf-8' } + ).trim(); + return output ? output.split('\n').filter(Boolean) : []; + } catch (error) { + console.error(`Git diff failed: ${error.message}`); + return []; + } +} + +/** + * Run the check. + * @returns {number} process exit code + */ +export function main() { + if (isReleaseBranch(process.env.HEAD_REF)) { + console.log('Automated release PR detected; skipping the changeset check.'); + return 0; + } + + const changedFiles = getChangedFiles(); + if (changedFiles.length === 0) { + console.log('No changed files found.'); + return 0; + } + + const { ok, sourceFiles, changesets } = evaluate(changedFiles); + + console.log(`Source files changed: ${sourceFiles.length}`); + sourceFiles.forEach((file) => console.log(` ${file}`)); + console.log(`Changesets added: ${changesets.length}`); + changesets.forEach((file) => console.log(` ${file}`)); + + if (ok) { + console.log('Changeset check passed.'); + return 0; + } + + console.error( + '::error::No changeset found in this PR. Add a release note in csharp/.changeset/' + ); + console.error(''); + console.error('To create a changeset:'); + console.error(' Add csharp/.changeset/YYYYMMDD_HHMMSS_description.md'); + console.error(''); + console.error('See csharp/.changeset/README.md for the expected format.'); + return 1; +} + +const entryPath = process.argv[1]; +const invokedDirectly = + typeof entryPath === 'string' && + entryPath.length > 0 && + import.meta.url === pathToFileURL(resolve(entryPath)).href; + +if (invokedDirectly) { + process.exit(main()); +} diff --git a/csharp/scripts/check-changeset.test.mjs b/csharp/scripts/check-changeset.test.mjs new file mode 100644 index 0000000..207e504 --- /dev/null +++ b/csharp/scripts/check-changeset.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + evaluate, + isChangeset, + isReleaseBranch, + isSourceFile, +} from './check-changeset.mjs'; + +test('recognises C# source files by package-relative path', () => { + assert.equal(isSourceFile('src/Lino.Objects.Codec/ObjectCodec.cs'), true); + assert.equal(isSourceFile('tests/Lino.Objects.Codec.Tests/BasicTypesTests.cs'), true); + assert.equal(isSourceFile('examples/BasicUsage/Program.cs'), true); + assert.equal(isSourceFile('scripts/check-changeset.mjs'), true); + assert.equal(isSourceFile('src/Lino.Objects.Codec/Lino.Objects.Codec.csproj'), true); +}); + +test('does not treat documentation or changesets as source', () => { + assert.equal(isSourceFile('README.md'), false); + assert.equal(isSourceFile('.changeset/20260820_000000_note.md'), false); + // Repo-root-relative paths must not match: git without --relative would + // report these, and matching them would break the check (issue #39). + assert.equal(isSourceFile('csharp/src/Lino.Objects.Codec/ObjectCodec.cs'), false); +}); + +test('recognises changesets but not their README', () => { + assert.equal(isChangeset('.changeset/20260820_071844_issue_39.md'), true); + assert.equal(isChangeset('.changeset/README.md'), false); + assert.equal(isChangeset('.changeset/config.json'), false); +}); + +test('fails when source changes carry no changeset', () => { + const result = evaluate(['src/Lino.Objects.Codec/ObjectCodec.cs']); + assert.equal(result.ok, false); + assert.deepEqual(result.sourceFiles, ['src/Lino.Objects.Codec/ObjectCodec.cs']); + assert.deepEqual(result.changesets, []); +}); + +test('passes when a changeset accompanies the source change', () => { + const result = evaluate([ + 'src/Lino.Objects.Codec/ObjectCodec.cs', + '.changeset/20260820_000000_note.md', + ]); + assert.equal(result.ok, true); +}); + +test('passes for documentation-only changes', () => { + assert.equal(evaluate(['README.md', 'docs/guide.md']).ok, true); +}); + +test('exempts automated release branches', () => { + assert.equal(isReleaseBranch('changeset-release/main'), true); + assert.equal(isReleaseBranch('changeset-manual-release-20260820'), true); + assert.equal(isReleaseBranch('issue-41-297a752a4939'), false); + assert.equal(isReleaseBranch(undefined), false); +}); diff --git a/python/scripts/validate_changeset.py b/python/scripts/validate_changeset.py index 15bee63..e5c6989 100644 --- a/python/scripts/validate_changeset.py +++ b/python/scripts/validate_changeset.py @@ -5,22 +5,102 @@ This script is the Python equivalent of validate-changeset.mjs from the JS template. It ensures that pull requests include changelog documentation. +Before issue #41 this script existed but no workflow ran it: the ``changelog`` +job in ``.github/workflows/python.yml`` had its own inline shell copy that +printed ``::warning::No changelog fragment found`` and then exited 0, so the +check could never fail. It also counted every file in ``changelog.d/``, which +meant a leftover fragment from an earlier, unreleased PR satisfied the check +for a new PR that added nothing. The fragment requirement is now decided from +the PR diff, matching rust/scripts/check-changelog-fragment.mjs and +csharp/scripts/check-changeset.mjs. + Usage: python scripts/validate_changeset.py +Environment variables (set by GitHub Actions): + GITHUB_BASE_REF - base branch name for the PR (defaults to "main") + HEAD_REF - PR head branch; automated release branches are exempt + Exit codes: - 0 - Validation passed (fragment found or no source changes) - 1 - Validation failed (source changes without fragment) + 0 - Validation passed (fragment added, or no source changes) + 1 - Validation failed (source changes without fragment, or a malformed one) Example CI usage: - name: Validate changelog fragment run: python scripts/validate_changeset.py """ +import os import re +import subprocess import sys from pathlib import Path +# Automated release PRs only consume fragments, so requiring a new one from +# them would deadlock the release jobs. +RELEASE_BRANCH_PREFIXES = ("changeset-release/", "changeset-manual-release-") + +# Paths are relative to the `python` package directory (git is invoked with +# --relative). Repo-root-relative paths such as "python/src/..." deliberately +# do not match: see issue #39 for the same bug in the Rust script. +SOURCE_PATTERNS = ( + re.compile(r"^src/"), + re.compile(r"^tests/"), + re.compile(r"^scripts/"), + re.compile(r"^pyproject\.toml$"), +) + + +def is_release_branch(head_ref: str | None) -> bool: + """Check whether a branch belongs to an automated release PR.""" + if not head_ref: + return False + return head_ref.startswith(RELEASE_BRANCH_PREFIXES) + + +def is_source_file(path: str) -> bool: + """Check whether a changed file requires a changelog fragment.""" + return any(pattern.search(path) for pattern in SOURCE_PATTERNS) + + +def is_fragment(path: str) -> bool: + """Check whether a changed file is a changelog fragment.""" + return ( + path.startswith("changelog.d/") + and path.endswith(".md") + and not path.endswith("README.md") + and not path.endswith(".j2") + ) + + +def evaluate(changed_files: list[str]) -> tuple[bool, list[str], list[str]]: + """Decide the outcome of the fragment requirement from a list of changed files. + + Returns (is_ok, source_files, fragments). + """ + source_files = [f for f in changed_files if is_source_file(f)] + fragments = [f for f in changed_files if is_fragment(f)] + return (not source_files or bool(fragments)), source_files, fragments + + +def get_changed_files() -> list[str]: + """Get the PR's changed files, relative to the `python` package directory.""" + base_ref = os.environ.get("GITHUB_BASE_REF") or "main" + print(f"Comparing against origin/{base_ref}...HEAD") + + try: + output = subprocess.run( + ["git", "diff", "--name-only", "--relative", f"origin/{base_ref}...HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, OSError) as error: + print(f"Git diff failed: {error}", file=sys.stderr) + return [] + + return [line for line in output.split("\n") if line] + def get_fragment_files(changelog_dir: Path) -> list[Path]: """Get list of changelog fragment files (excluding README and template).""" @@ -79,7 +159,10 @@ def validate_fragment_content(fragment_path: Path) -> tuple[bool, str]: def main() -> int: """Main entry point.""" - # Determine project root and changelog directory + if is_release_branch(os.environ.get("HEAD_REF")): + print("Automated release PR detected; skipping the changelog check.") + return 0 + script_dir = Path(__file__).parent project_root = script_dir.parent changelog_dir = project_root / "changelog.d" @@ -87,15 +170,19 @@ def main() -> int: print("Validating changelog fragments...") print() - # Get fragment files - fragments = get_fragment_files(changelog_dir) - fragment_count = len(fragments) + changed_files = get_changed_files() + is_ok, source_files, added_fragments = evaluate(changed_files) - print(f"Found {fragment_count} changelog fragment(s)") + print(f"Source files changed: {len(source_files)}") + for path in source_files: + print(f" {path}") + print(f"Changelog fragments added: {len(added_fragments)}") + for path in added_fragments: + print(f" {path}") + print() - if fragment_count == 0: - print() - print("WARNING: No changelog fragment found!") + if not is_ok: + print("::error::No changelog fragment found in this PR.") print() print("To document your changes, create a changelog fragment:") print() @@ -107,25 +194,15 @@ def main() -> int: print(" python scripts/create_manual_changeset.py patch --description 'Your changes'") print() print("See changelog.d/README.md for more information.") - print() - - # This is currently a warning, not a failure - # Change to "return 1" to make it required - return 0 - - if fragment_count > 1: - print() - print(f"WARNING: Found {fragment_count} fragments. Usually PRs should have only one.") - print("Fragments found:") - for f in fragments: - print(f" - {f.name}") - print() + return 1 - # Validate each fragment + # Validate the content of every fragment currently in the directory, so a + # malformed one fails the PR that introduced it rather than the release. + fragments = get_fragment_files(changelog_dir) all_valid = True for fragment in fragments: - is_valid, error = validate_fragment_content(fragment) - if is_valid: + valid, error = validate_fragment_content(fragment) + if valid: print(f" [OK] {fragment.name}") else: print(f" [FAIL] {error}") @@ -136,21 +213,21 @@ def main() -> int: if all_valid: print("Changelog validation passed!") return 0 - else: - print("Changelog validation FAILED!") - print() - print("Expected fragment format:") - print() - print(" ### Added") - print(" - Description of new feature") - print() - print(" ### Changed") - print(" - Description of change") - print() - print(" ### Fixed") - print(" - Description of bug fix") - print() - return 1 + + print("Changelog validation FAILED!") + print() + print("Expected fragment format:") + print() + print(" ### Added") + print(" - Description of new feature") + print() + print(" ### Changed") + print(" - Description of change") + print() + print(" ### Fixed") + print(" - Description of bug fix") + print() + return 1 if __name__ == "__main__": diff --git a/python/tests/test_validate_changeset.py b/python/tests/test_validate_changeset.py new file mode 100644 index 0000000..eff42f7 --- /dev/null +++ b/python/tests/test_validate_changeset.py @@ -0,0 +1,86 @@ +"""Tests for the changelog-fragment gate (issue #41). + +The workflow used to warn and exit 0 when a PR changed Python sources without +a changelog fragment, so the check could never fail. These tests pin the +enforced behaviour. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "validate_changeset.py" +_spec = importlib.util.spec_from_file_location("validate_changeset", _SCRIPT) +assert _spec is not None and _spec.loader is not None +validate_changeset = importlib.util.module_from_spec(_spec) +sys.modules["validate_changeset"] = validate_changeset +_spec.loader.exec_module(validate_changeset) + + +def test_recognizes_python_sources_by_package_relative_path() -> None: + assert validate_changeset.is_source_file("src/link_notation_objects_codec/codec.py") + assert validate_changeset.is_source_file("tests/test_format.py") + assert validate_changeset.is_source_file("scripts/bump_version.py") + assert validate_changeset.is_source_file("pyproject.toml") + + +def test_ignores_docs_and_repo_root_relative_paths() -> None: + assert not validate_changeset.is_source_file("README.md") + assert not validate_changeset.is_source_file("changelog.d/20260820_note.md") + # git without --relative would report this form; matching it would break + # the check in this monorepo (issue #39). + assert not validate_changeset.is_source_file("python/src/link_notation_objects_codec/codec.py") + + +def test_recognizes_fragments_but_not_readme_or_template() -> None: + assert validate_changeset.is_fragment("changelog.d/20260820_071844_note.md") + assert not validate_changeset.is_fragment("changelog.d/README.md") + assert not validate_changeset.is_fragment("changelog.d/new_fragment.md.j2") + + +def test_source_change_without_fragment_fails() -> None: + ok, sources, fragments = validate_changeset.evaluate( + ["src/link_notation_objects_codec/codec.py"] + ) + assert ok is False + assert sources == ["src/link_notation_objects_codec/codec.py"] + assert fragments == [] + + +def test_source_change_with_fragment_passes() -> None: + ok, _, fragments = validate_changeset.evaluate( + ["src/link_notation_objects_codec/codec.py", "changelog.d/20260820_note.md"] + ) + assert ok is True + assert fragments == ["changelog.d/20260820_note.md"] + + +def test_docs_only_change_passes() -> None: + ok, sources, _ = validate_changeset.evaluate(["README.md", "docs/guide.md"]) + assert ok is True + assert sources == [] + + +def test_release_branches_are_exempt() -> None: + assert validate_changeset.is_release_branch("changeset-release/main") + assert validate_changeset.is_release_branch("changeset-manual-release-20260820") + assert not validate_changeset.is_release_branch("issue-41-297a752a4939") + assert not validate_changeset.is_release_branch(None) + + +def test_fragment_content_validation_rejects_missing_category(tmp_path: Path) -> None: + fragment = tmp_path / "20260820_note.md" + fragment.write_text("just some prose without a heading\n") + valid, error = validate_changeset.validate_fragment_content(fragment) + assert valid is False + assert "missing category heading" in error + + +def test_fragment_content_validation_accepts_a_well_formed_fragment(tmp_path: Path) -> None: + fragment = tmp_path / "20260820_note.md" + fragment.write_text("### Fixed\n\n- Something that was broken\n") + valid, error = validate_changeset.validate_fragment_content(fragment) + assert valid is True + assert error == "" From 67ea0a860ebc752cf0adcf5def2937aac0c377b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:40:40 +0000 Subject: [PATCH 21/29] ci(python): test on macOS and Windows too Python was the only implementation tested on a single operating system; JavaScript, Rust and C# each run a three-OS matrix. A Windows-only defect (path separators, line endings, locale-dependent encoding) would have shipped to PyPI unnoticed. Coverage is uploaded from the ubuntu leg only. Note: the job name changes from "Test (Python 3.13)" to "Test (Python 3.13 on )", so the required-check list in branch protection needs updating. --- .github/workflows/python.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f84e31d..a2f5cea 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -90,15 +90,23 @@ jobs: working-directory: ./python run: python scripts/check_file_size.py - # Test on latest Python version only + # Test matrix: Python on multiple OS. + # Until issue #41 this job ran on ubuntu-latest only, while the JavaScript, + # Rust and C# suites all ran on three operating systems; a Windows-only + # failure (line endings, path separators, locale-dependent encoding) would + # have reached PyPI unnoticed. test: timeout-minutes: 30 # Read-only check: a newer run supersedes this one. concurrency: - group: check-${{ github.workflow }}-${{ github.ref }}-test + group: check-${{ github.workflow }}-${{ github.ref }}-test-${{ matrix.os }} cancel-in-progress: true - name: Test (Python 3.13) - runs-on: ubuntu-latest + name: Test (Python 3.13 on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v7 with: @@ -132,7 +140,10 @@ jobs: working-directory: ./python run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term + # One upload per commit: the matrix legs produce identical coverage for a + # pure-Python package, and uploading all three only adds Codecov noise. - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v7 with: files: ./python/coverage.xml From 51f7ed41ae719d799e50bb4c7597f26cffe46d2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:42:24 +0000 Subject: [PATCH 22/29] chore: add release notes for the CI/CD fixes in all four languages --- .../20260820_083000_issue_41_cicd_hardening.md | 10 ++++++++++ .../20260820_083000_issue_41_cicd_hardening.md | 11 +++++++++++ .../20260820_083000_issue_41_cicd_hardening.md | 14 ++++++++++++++ .../20260820_083000_issue_41_cicd_hardening.md | 13 +++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md create mode 100644 js/.changeset/20260820_083000_issue_41_cicd_hardening.md create mode 100644 python/changelog.d/20260820_083000_issue_41_cicd_hardening.md create mode 100644 rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md diff --git a/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md b/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md new file mode 100644 index 0000000..e1b6304 --- /dev/null +++ b/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md @@ -0,0 +1,10 @@ +--- +'Lino.Objects.Codec': patch +--- + +Pack `README.md` into the NuGet package (`PackageReadmeFile`), removing the +"Readme missing" warning `dotnet pack` printed on every release, so the gallery +page renders the project documentation. Part of the CI/CD clean-up in +[issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41), +which also makes the C# changeset check able to fail, enforces the 1500-line +file limit and finally runs the `csharp/scripts` unit tests in CI. diff --git a/js/.changeset/20260820_083000_issue_41_cicd_hardening.md b/js/.changeset/20260820_083000_issue_41_cicd_hardening.md new file mode 100644 index 0000000..8f4663f --- /dev/null +++ b/js/.changeset/20260820_083000_issue_41_cicd_hardening.md @@ -0,0 +1,11 @@ +--- +'lino-objects-codec': patch +--- + +Refactor `ObjectCodec._encodeValue` and `ObjectCodec._decodeLink` into small +dispatch methods so the JavaScript sources lint clean: the three ESLint +`complexity`/`max-statements` warnings the pipeline had been printing on every +run are gone. Behaviour and the public API are unchanged and the full 244-test +suite is untouched. Part of the CI/CD clean-up in +[issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41), +which also fixes the seven `npm audit` advisories in the dev dependency tree. diff --git a/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md b/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md new file mode 100644 index 0000000..64bd3a0 --- /dev/null +++ b/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md @@ -0,0 +1,14 @@ +### Changed + +- Declare the licence with the PEP 639 SPDX expression (`license = "Unlicense"` + plus `license-files`) and ship `python/LICENSE` in the distribution. This + removes the setuptools deprecation warning that every build emitted and makes + the wheel metadata state `License-Expression: Unlicense`. See + [issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41). + +### Fixed + +- The changelog check can now fail. It previously printed a warning and exited + 0, so a pull request could change `python/src` with no release note and still + show a green check, and it counted leftover fragments from earlier unreleased + pull requests. The requirement is now decided from the pull-request diff. diff --git a/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md b/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md new file mode 100644 index 0000000..13028da --- /dev/null +++ b/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md @@ -0,0 +1,13 @@ +--- +bump: patch +--- + +### Changed + +- CI/CD hardening from + [issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41): + every action moves to a Node 24 major, each job gets a `timeout-minutes` and + its own concurrency group, release jobs queue instead of racing so a + `cargo publish` can no longer be cancelled mid-flight, and pull requests are + now validated against a fresh merge with the base branch rather than a stale + merge preview. No change to the crate's code or public API. From f6f143d1e00ded7164ce4b73e4ad51d9c6d31a28 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:49:03 +0000 Subject: [PATCH 23/29] fix(ci): make change detection use package-relative paths `git diff --name-only` prints paths relative to the repository root, but `detect-code-changes.mjs` runs with `working-directory: ./js` (and ./rust) and compared those paths against package-relative prefixes. In this monorepo the real paths are `js/examples/demo.mjs`, not `examples/demo.mjs`, so: * the documented `examples/`, `experiments/`, `docs/` and `.changeset/` exclusions never matched -- an examples-only pull request was reported as a code change and asked for a changeset it does not need (false positive); * `package-changed` could never become true (false negative); * Rust's `toml-changed` fired on any .toml in the repository, including `python/pyproject.toml`. This is the same class of defect as issue #39. The prefix is now resolved at run time with `git rev-parse --show-prefix`, which keeps the scripts correct in a single-package checkout too, where the prefix is empty. experiments/detect-code-changes-monorepo-paths.sh reproduces the original behaviour end to end; the new unit tests pin it down per case. The js lint job now runs `node --test scripts/*.test.mjs`, which nothing did before. --- .github/workflows/js.yml | 7 + .../detect-code-changes-monorepo-paths.sh | 45 ++++ js/scripts/detect-code-changes.mjs | 140 +++++++++---- js/scripts/detect-code-changes.test.mjs | 80 +++++++ rust/scripts/detect-code-changes.mjs | 195 ++++++++++++------ rust/scripts/detect-code-changes.test.mjs | 77 +++++++ 6 files changed, 447 insertions(+), 97 deletions(-) create mode 100644 experiments/detect-code-changes-monorepo-paths.sh create mode 100644 js/scripts/detect-code-changes.test.mjs create mode 100644 rust/scripts/detect-code-changes.test.mjs diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index bf788b3..d76bff9 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -214,6 +214,13 @@ jobs: working-directory: ./js run: npm run check:duplication + # `npm test` only covers `tests/`, so the CI helper scripts under + # `js/scripts` had no automated coverage before issue #41 even though the + # release jobs depend on them. + - name: Run CI script tests + working-directory: ./js + run: node --test scripts/*.test.mjs + # Test matrix: Node.js on multiple OS test: timeout-minutes: 30 diff --git a/experiments/detect-code-changes-monorepo-paths.sh b/experiments/detect-code-changes-monorepo-paths.sh new file mode 100644 index 0000000..48a93d0 --- /dev/null +++ b/experiments/detect-code-changes-monorepo-paths.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Reproduces the monorepo path bug in `js/scripts/detect-code-changes.mjs` and +# `rust/scripts/detect-code-changes.mjs` (same class as issue #39). +# +# `git diff --name-only` prints paths relative to the *repository root*, but the +# workflow runs the script with `working-directory: ./js`, and the script +# compares those paths against package-relative prefixes such as `examples/` and +# against the exact string `package.json`. In a monorepo the real paths are +# `js/examples/...` and `js/package.json`, so: +# +# * `examples/`, `experiments/`, `docs/` and `.changeset/` are never excluded +# -> an examples-only pull request is reported as a code change and is asked +# for a changeset it does not need (false positive); +# * `package-changed` can never be true (false negative). +# +# Usage: bash experiments/detect-code-changes-monorepo-paths.sh [path-to-script] +set -euo pipefail + +SCRIPT="${1:-$(cd "$(dirname "$0")/.." && pwd)/js/scripts/detect-code-changes.mjs}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cd "$WORK" +git init -q . +git config user.email ci@example.com +git config user.name CI + +mkdir -p js/scripts js/examples +cp "$SCRIPT" js/scripts/detect-code-changes.mjs +echo '{}' > js/package.json +git add -A +git commit -qm "base" +BASE="$(git rev-parse HEAD)" + +# A pull request that only touches an example. By the script's own documentation +# this is excluded from "code changes". +echo "// demo" > js/examples/demo.mjs +git add -A +git commit -qm "docs: add an example" +HEAD_SHA="$(git rev-parse HEAD)" + +cd js +echo "--- detect-code-changes.mjs on an examples-only pull request ---" +GITHUB_EVENT_NAME=pull_request GITHUB_BASE_SHA="$BASE" GITHUB_HEAD_SHA="$HEAD_SHA" \ + node scripts/detect-code-changes.mjs diff --git a/js/scripts/detect-code-changes.mjs b/js/scripts/detect-code-changes.mjs index a6cd67b..d3de25e 100644 --- a/js/scripts/detect-code-changes.mjs +++ b/js/scripts/detect-code-changes.mjs @@ -37,6 +37,7 @@ import { execSync } from 'child_process'; import { appendFileSync } from 'fs'; +import { pathToFileURL } from 'url'; /** * Execute a shell command and return trimmed output @@ -70,7 +71,7 @@ function setOutput(name, value) { * Get the list of changed files between two commits * @returns {string[]} Array of changed file paths */ -function getChangedFiles() { +export function getChangedFiles() { const eventName = process.env.GITHUB_EVENT_NAME || 'local'; if (eventName === 'pull_request') { @@ -108,12 +109,57 @@ function getChangedFiles() { } } +/** + * Path of the current working directory relative to the repository root. + * + * `git diff --name-only` always prints paths relative to the **repository + * root**, but this script runs with `working-directory: ./js` and compares + * those paths against package-relative prefixes such as `examples/`. In this + * monorepo the real paths are `js/examples/...`, so before issue #41 none of + * the exclusions ever matched and `package.json` could never be detected -- the + * same class of defect as issue #39. Resolving the prefix at run time keeps the + * script correct both here and in a single-package checkout, where the prefix + * is empty. + * + * @returns {string} The prefix, ending with `/`, or `''` at the repository root + */ +export function getPathPrefix() { + return exec('git rev-parse --show-prefix'); +} + +/** + * Re-express repository-root-relative paths as package-relative paths, + * dropping everything that lives outside this package. + * + * @param {string[]} changedFiles - Repository-root-relative paths + * @param {string} prefix - Package prefix such as `js/` + * @returns {string[]} Package-relative paths + */ +export function toPackagePaths(changedFiles, prefix) { + if (!prefix) { + return changedFiles; + } + return changedFiles + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +/** + * Check whether a repository-root-relative path is a workflow definition. + * + * @param {string} filePath - Repository-root-relative path + * @returns {boolean} True for files under `.github/workflows/` + */ +export function isWorkflowFile(filePath) { + return filePath.startsWith('.github/workflows/'); +} + /** * Check if a file should be excluded from code changes detection * @param {string} filePath - The file path to check * @returns {boolean} True if the file should be excluded */ -function isExcludedFromCodeChanges(filePath) { +export function isExcludedFromCodeChanges(filePath) { // Exclude markdown files in any folder if (filePath.endsWith('.md')) { return true; @@ -131,14 +177,57 @@ function isExcludedFromCodeChanges(filePath) { return false; } +/** + * Classify a set of repository-root-relative changed paths. + * + * Split out from `detectChanges` so the classification can be unit tested + * without a git repository. + * + * @param {string[]} changedFiles - Repository-root-relative changed paths + * @param {string} prefix - Package prefix such as `js/` + * @returns {{outputs: Record, packageFiles: string[], codeChangedFiles: string[]}} + */ +export function classifyChanges(changedFiles, prefix) { + const packageFiles = toPackagePaths(changedFiles, prefix); + const workflowFiles = changedFiles.filter(isWorkflowFile); + + // Code changes are judged on package-relative paths, so the documented + // `examples/`, `experiments/`, `docs/` and `.changeset/` exclusions apply. + const codeChangedFiles = packageFiles.filter( + (file) => !isExcludedFromCodeChanges(file) + ); + + // A workflow change still counts as a code change: it can alter how this + // package is built and published even when no package file moved. + const codePattern = /\.(mjs|js|json|yml|yaml)$/; + const codeChanged = + codeChangedFiles.some((file) => codePattern.test(file)) || + workflowFiles.length > 0; + + return { + packageFiles, + codeChangedFiles, + outputs: { + 'mjs-changed': String(packageFiles.some((f) => f.endsWith('.mjs'))), + 'js-changed': String(packageFiles.some((f) => f.endsWith('.js'))), + 'package-changed': String(packageFiles.includes('package.json')), + 'docs-changed': String(packageFiles.some((f) => f.endsWith('.md'))), + 'workflow-changed': String(workflowFiles.length > 0), + 'any-code-changed': String(codeChanged), + }, + }; +} + /** * Main function to detect changes */ -function detectChanges() { +export function detectChanges() { console.log('Detecting file changes for CI/CD...\n'); const changedFiles = getChangedFiles(); + const prefix = getPathPrefix(); + console.log(`Package prefix: ${prefix || '(repository root)'}`); console.log('Changed files:'); if (changedFiles.length === 0) { console.log(' (none)'); @@ -147,34 +236,9 @@ function detectChanges() { } console.log(''); - // Detect .mjs file changes - const mjsChanged = changedFiles.some((file) => file.endsWith('.mjs')); - setOutput('mjs-changed', mjsChanged ? 'true' : 'false'); - - // Detect .js file changes - const jsChanged = changedFiles.some((file) => file.endsWith('.js')); - setOutput('js-changed', jsChanged ? 'true' : 'false'); + const { codeChangedFiles, outputs } = classifyChanges(changedFiles, prefix); - // Detect package.json changes - const packageChanged = changedFiles.some((file) => file === 'package.json'); - setOutput('package-changed', packageChanged ? 'true' : 'false'); - - // Detect documentation changes (any .md file) - const docsChanged = changedFiles.some((file) => file.endsWith('.md')); - setOutput('docs-changed', docsChanged ? 'true' : 'false'); - - // Detect workflow changes - const workflowChanged = changedFiles.some((file) => - file.startsWith('.github/workflows/') - ); - setOutput('workflow-changed', workflowChanged ? 'true' : 'false'); - - // Detect code changes (excluding docs, changesets, experiments, examples folders, and markdown files) - const codeChangedFiles = changedFiles.filter( - (file) => !isExcludedFromCodeChanges(file) - ); - - console.log('\nFiles considered as code changes:'); + console.log('Files considered as code changes:'); if (codeChangedFiles.length === 0) { console.log(' (none)'); } else { @@ -182,13 +246,17 @@ function detectChanges() { } console.log(''); - // Check if any code files changed (.mjs, .js, .json, .yml, .yaml, or workflow files) - const codePattern = /\.(mjs|js|json|yml|yaml)$|\.github\/workflows\//; - const codeChanged = codeChangedFiles.some((file) => codePattern.test(file)); - setOutput('any-code-changed', codeChanged ? 'true' : 'false'); + for (const [name, value] of Object.entries(outputs)) { + setOutput(name, value); + } console.log('\nChange detection completed.'); } -// Run the detection -detectChanges(); +// Run the detection unless this module was imported by a test. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + detectChanges(); +} diff --git a/js/scripts/detect-code-changes.test.mjs b/js/scripts/detect-code-changes.test.mjs new file mode 100644 index 0000000..0693130 --- /dev/null +++ b/js/scripts/detect-code-changes.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + classifyChanges, + isExcludedFromCodeChanges, + isWorkflowFile, + toPackagePaths, +} from './detect-code-changes.mjs'; + +// Regression guard for the monorepo path bug found while solving issue #41 +// (same class as issue #39): `git diff --name-only` yields repository-root +// paths, so every package-relative comparison has to strip the `js/` prefix +// first. +test('toPackagePaths strips the package prefix and drops foreign packages', () => { + const files = [ + 'js/src/index.mjs', + 'rust/src/lib.rs', + '.github/workflows/js.yml', + ]; + assert.deepEqual(toPackagePaths(files, 'js/'), ['src/index.mjs']); +}); + +test('toPackagePaths is a no-op at the repository root', () => { + const files = ['src/index.mjs']; + assert.deepEqual(toPackagePaths(files, ''), files); +}); + +test('an examples-only pull request is not a code change', () => { + const { outputs } = classifyChanges(['js/examples/demo.mjs'], 'js/'); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['mjs-changed'], 'true'); +}); + +test('a changeset-only pull request is not a code change', () => { + const { outputs } = classifyChanges( + ['js/.changeset/happy-pandas-run.md'], + 'js/' + ); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['docs-changed'], 'true'); +}); + +test('package.json is detected through the package prefix', () => { + const { outputs } = classifyChanges(['js/package.json'], 'js/'); + assert.equal(outputs['package-changed'], 'true'); + assert.equal(outputs['any-code-changed'], 'true'); +}); + +test('a source change is a code change', () => { + const { outputs } = classifyChanges(['js/src/codec.mjs'], 'js/'); + assert.equal(outputs['any-code-changed'], 'true'); + assert.equal(outputs['mjs-changed'], 'true'); +}); + +test('a workflow change counts as a code change', () => { + const { outputs } = classifyChanges(['.github/workflows/js.yml'], 'js/'); + assert.equal(outputs['workflow-changed'], 'true'); + assert.equal(outputs['any-code-changed'], 'true'); +}); + +test('changes in another package are ignored', () => { + const { outputs } = classifyChanges( + ['python/src/lino_objects_codec/codec.py'], + 'js/' + ); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['docs-changed'], 'false'); +}); + +test('isWorkflowFile only matches repository-root workflow paths', () => { + assert.equal(isWorkflowFile('.github/workflows/js.yml'), true); + assert.equal(isWorkflowFile('js/.github/workflows/js.yml'), false); +}); + +test('markdown is excluded from code changes anywhere in the package', () => { + assert.equal(isExcludedFromCodeChanges('docs/guide.md'), true); + assert.equal(isExcludedFromCodeChanges('src/README.md'), true); + assert.equal(isExcludedFromCodeChanges('src/index.mjs'), false); +}); diff --git a/rust/scripts/detect-code-changes.mjs b/rust/scripts/detect-code-changes.mjs index 065c6ed..df6f5d0 100644 --- a/rust/scripts/detect-code-changes.mjs +++ b/rust/scripts/detect-code-changes.mjs @@ -35,8 +35,9 @@ * - any-code-changed: 'true' if any code files changed (excludes docs, changelog.d, experiments, examples) */ -import { execSync } from 'child_process'; -import { appendFileSync } from 'fs'; +import { execSync } from "child_process"; +import { appendFileSync } from "fs"; +import { pathToFileURL } from "url"; /** * Execute a shell command and return trimmed output @@ -45,11 +46,11 @@ import { appendFileSync } from 'fs'; */ function exec(command) { try { - return execSync(command, { encoding: 'utf-8' }).trim(); + return execSync(command, { encoding: "utf-8" }).trim(); } catch (error) { console.error(`Error executing command: ${command}`); console.error(error.message); - return ''; + return ""; } } @@ -70,10 +71,10 @@ function setOutput(name, value) { * Get the list of changed files between two commits * @returns {string[]} Array of changed file paths */ -function getChangedFiles() { - const eventName = process.env.GITHUB_EVENT_NAME || 'local'; +export function getChangedFiles() { + const eventName = process.env.GITHUB_EVENT_NAME || "local"; - if (eventName === 'pull_request') { + if (eventName === "pull_request") { const baseSha = process.env.GITHUB_BASE_SHA; const headSha = process.env.GITHUB_HEAD_SHA; @@ -82,13 +83,13 @@ function getChangedFiles() { try { // Ensure we have the base commit try { - execSync(`git cat-file -e ${baseSha}`, { stdio: 'ignore' }); + execSync(`git cat-file -e ${baseSha}`, { stdio: "ignore" }); } catch { - console.log('Base commit not available locally, attempting fetch...'); - execSync(`git fetch origin ${baseSha}`, { stdio: 'inherit' }); + console.log("Base commit not available locally, attempting fetch..."); + execSync(`git fetch origin ${baseSha}`, { stdio: "inherit" }); } const output = exec(`git diff --name-only ${baseSha} ${headSha}`); - return output ? output.split('\n').filter(Boolean) : []; + return output ? output.split("\n").filter(Boolean) : []; } catch (error) { console.error(`Git diff failed: ${error.message}`); } @@ -96,31 +97,81 @@ function getChangedFiles() { } // For push events or fallback - console.log('Comparing HEAD^ to HEAD'); + console.log("Comparing HEAD^ to HEAD"); try { - const output = exec('git diff --name-only HEAD^ HEAD'); - return output ? output.split('\n').filter(Boolean) : []; + const output = exec("git diff --name-only HEAD^ HEAD"); + return output ? output.split("\n").filter(Boolean) : []; } catch { // If HEAD^ doesn't exist (first commit), list all files in HEAD - console.log('HEAD^ not available, listing all files in HEAD'); - const output = exec('git ls-tree --name-only -r HEAD'); - return output ? output.split('\n').filter(Boolean) : []; + console.log("HEAD^ not available, listing all files in HEAD"); + const output = exec("git ls-tree --name-only -r HEAD"); + return output ? output.split("\n").filter(Boolean) : []; } } +/** + * Path of the current working directory relative to the repository root. + * + * `git diff --name-only` always prints paths relative to the **repository + * root**, but this script runs with `working-directory: ./rust` and compares + * those paths against package-relative prefixes such as `examples/`. In this + * monorepo the real paths are `rust/examples/...`, so before issue #41 none of + * the exclusions ever matched and `Cargo.toml` could never be detected -- the + * same class of defect as issue #39. Resolving the prefix at run time keeps the + * script correct both here and in a single-package checkout, where the prefix + * is empty. + * + * @returns {string} The prefix, ending with `/`, or `''` at the repository root + */ +export function getPathPrefix() { + return exec("git rev-parse --show-prefix"); +} + +/** + * Re-express repository-root-relative paths as package-relative paths, + * dropping everything that lives outside this package. + * + * @param {string[]} changedFiles - Repository-root-relative paths + * @param {string} prefix - Package prefix such as `rust/` + * @returns {string[]} Package-relative paths + */ +export function toPackagePaths(changedFiles, prefix) { + if (!prefix) { + return changedFiles; + } + return changedFiles + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +/** + * Check whether a repository-root-relative path is a workflow definition. + * + * @param {string} filePath - Repository-root-relative path + * @returns {boolean} True for files under `.github/workflows/` + */ +export function isWorkflowFile(filePath) { + return filePath.startsWith(".github/workflows/"); +} + /** * Check if a file should be excluded from code changes detection * @param {string} filePath - The file path to check * @returns {boolean} True if the file should be excluded */ -function isExcludedFromCodeChanges(filePath) { +export function isExcludedFromCodeChanges(filePath) { // Exclude markdown files in any folder - if (filePath.endsWith('.md')) { + if (filePath.endsWith(".md")) { return true; } // Exclude specific folders from code changes - const excludedFolders = ['changelog.d/', 'docs/', 'experiments/', 'examples/']; + const excludedFolders = [ + "changelog.d/", + "docs/", + "experiments/", + "examples/", + ]; for (const folder of excludedFolders) { if (filePath.startsWith(folder)) { @@ -131,64 +182,86 @@ function isExcludedFromCodeChanges(filePath) { return false; } +/** + * Classify a set of repository-root-relative changed paths. + * + * Split out from `detectChanges` so the classification can be unit tested + * without a git repository. + * + * @param {string[]} changedFiles - Repository-root-relative changed paths + * @param {string} prefix - Package prefix such as `rust/` + * @returns {{outputs: Record, packageFiles: string[], codeChangedFiles: string[]}} + */ +export function classifyChanges(changedFiles, prefix) { + const packageFiles = toPackagePaths(changedFiles, prefix); + const workflowFiles = changedFiles.filter(isWorkflowFile); + + // Code changes are judged on package-relative paths, so the documented + // `examples/`, `experiments/`, `docs/` and `.changeset/` exclusions apply. + const codeChangedFiles = packageFiles.filter( + (file) => !isExcludedFromCodeChanges(file), + ); + + // A workflow change still counts as a code change: it can alter how this + // package is built and published even when no package file moved. + const codePattern = /\.(rs|toml|mjs|js|yml|yaml)$/; + const codeChanged = + codeChangedFiles.some((file) => codePattern.test(file)) || + workflowFiles.length > 0; + + return { + packageFiles, + codeChangedFiles, + outputs: { + "rs-changed": String(packageFiles.some((f) => f.endsWith(".rs"))), + "toml-changed": String(packageFiles.some((f) => f.endsWith(".toml"))), + "mjs-changed": String(packageFiles.some((f) => f.endsWith(".mjs"))), + "docs-changed": String(packageFiles.some((f) => f.endsWith(".md"))), + "workflow-changed": String(workflowFiles.length > 0), + "any-code-changed": String(codeChanged), + }, + }; +} + /** * Main function to detect changes */ -function detectChanges() { - console.log('Detecting file changes for CI/CD...\n'); +export function detectChanges() { + console.log("Detecting file changes for CI/CD...\n"); const changedFiles = getChangedFiles(); + const prefix = getPathPrefix(); - console.log('Changed files:'); + console.log(`Package prefix: ${prefix || "(repository root)"}`); + console.log("Changed files:"); if (changedFiles.length === 0) { - console.log(' (none)'); + console.log(" (none)"); } else { changedFiles.forEach((file) => console.log(` ${file}`)); } - console.log(''); + console.log(""); - // Detect .rs file changes (Rust source) - const rsChanged = changedFiles.some((file) => file.endsWith('.rs')); - setOutput('rs-changed', rsChanged ? 'true' : 'false'); + const { codeChangedFiles, outputs } = classifyChanges(changedFiles, prefix); - // Detect .toml file changes (Cargo.toml, Cargo.lock, etc.) - const tomlChanged = changedFiles.some((file) => file.endsWith('.toml')); - setOutput('toml-changed', tomlChanged ? 'true' : 'false'); - - // Detect .mjs file changes (scripts) - const mjsChanged = changedFiles.some((file) => file.endsWith('.mjs')); - setOutput('mjs-changed', mjsChanged ? 'true' : 'false'); - - // Detect documentation changes (any .md file) - const docsChanged = changedFiles.some((file) => file.endsWith('.md')); - setOutput('docs-changed', docsChanged ? 'true' : 'false'); - - // Detect workflow changes - const workflowChanged = changedFiles.some((file) => - file.startsWith('.github/workflows/') - ); - setOutput('workflow-changed', workflowChanged ? 'true' : 'false'); - - // Detect code changes (excluding docs, changelog.d, experiments, examples folders, and markdown files) - const codeChangedFiles = changedFiles.filter( - (file) => !isExcludedFromCodeChanges(file) - ); - - console.log('\nFiles considered as code changes:'); + console.log("Files considered as code changes:"); if (codeChangedFiles.length === 0) { - console.log(' (none)'); + console.log(" (none)"); } else { codeChangedFiles.forEach((file) => console.log(` ${file}`)); } - console.log(''); + console.log(""); - // Check if any code files changed (.rs, .toml, .mjs, .yml, .yaml, or workflow files) - const codePattern = /\.(rs|toml|mjs|js|yml|yaml)$|\.github\/workflows\//; - const codeChanged = codeChangedFiles.some((file) => codePattern.test(file)); - setOutput('any-code-changed', codeChanged ? 'true' : 'false'); + for (const [name, value] of Object.entries(outputs)) { + setOutput(name, value); + } - console.log('\nChange detection completed.'); + console.log("\nChange detection completed."); } -// Run the detection -detectChanges(); +// Run the detection unless this module was imported by a test. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + detectChanges(); +} diff --git a/rust/scripts/detect-code-changes.test.mjs b/rust/scripts/detect-code-changes.test.mjs new file mode 100644 index 0000000..85eefe3 --- /dev/null +++ b/rust/scripts/detect-code-changes.test.mjs @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + classifyChanges, + isExcludedFromCodeChanges, + isWorkflowFile, + toPackagePaths, +} from "./detect-code-changes.mjs"; + +// Regression guard for the monorepo path bug found while solving issue #41 +// (same class as issue #39): `git diff --name-only` yields repository-root +// paths, so every package-relative comparison has to strip the `rust/` prefix +// first. +test("toPackagePaths strips the package prefix and drops foreign packages", () => { + const files = [ + "rust/src/lib.rs", + "js/src/index.mjs", + ".github/workflows/rust.yml", + ]; + assert.deepEqual(toPackagePaths(files, "rust/"), ["src/lib.rs"]); +}); + +test("toPackagePaths is a no-op at the repository root", () => { + const files = ["src/lib.rs"]; + assert.deepEqual(toPackagePaths(files, ""), files); +}); + +test("an examples-only pull request is not a code change", () => { + const { outputs } = classifyChanges(["rust/examples/demo.rs"], "rust/"); + assert.equal(outputs["any-code-changed"], "false"); + assert.equal(outputs["rs-changed"], "true"); +}); + +test("a changelog-fragment-only pull request is not a code change", () => { + const { outputs } = classifyChanges( + ["rust/changelog.d/20260820_fix.md"], + "rust/", + ); + assert.equal(outputs["any-code-changed"], "false"); + assert.equal(outputs["docs-changed"], "true"); +}); + +test("Cargo.toml is detected through the package prefix", () => { + const { outputs } = classifyChanges(["rust/Cargo.toml"], "rust/"); + assert.equal(outputs["toml-changed"], "true"); + assert.equal(outputs["any-code-changed"], "true"); +}); + +test("a source change is a code change", () => { + const { outputs } = classifyChanges(["rust/src/codec.rs"], "rust/"); + assert.equal(outputs["any-code-changed"], "true"); + assert.equal(outputs["rs-changed"], "true"); +}); + +test("a workflow change counts as a code change", () => { + const { outputs } = classifyChanges([".github/workflows/rust.yml"], "rust/"); + assert.equal(outputs["workflow-changed"], "true"); + assert.equal(outputs["any-code-changed"], "true"); +}); + +test("another package's TOML no longer looks like a Rust change", () => { + const { outputs } = classifyChanges(["python/pyproject.toml"], "rust/"); + assert.equal(outputs["toml-changed"], "false"); + assert.equal(outputs["any-code-changed"], "false"); +}); + +test("isWorkflowFile only matches repository-root workflow paths", () => { + assert.equal(isWorkflowFile(".github/workflows/rust.yml"), true); + assert.equal(isWorkflowFile("rust/.github/workflows/rust.yml"), false); +}); + +test("markdown is excluded from code changes anywhere in the package", () => { + assert.equal(isExcludedFromCodeChanges("docs/guide.md"), true); + assert.equal(isExcludedFromCodeChanges("src/README.md"), true); + assert.equal(isExcludedFromCodeChanges("src/lib.rs"), false); +}); From 967430dcecb2e9fd9667faa74954dabccd7c7c4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:53:19 +0000 Subject: [PATCH 24/29] ci: gate the Python and C# jobs on change detection (template parity) All four organisation templates ship a `detect-changes` job; this repository had one for JavaScript and Rust only, so a documentation-only pull request ran the full .NET and Python matrices. The new scripts follow the fixed package-relative semantics from the previous commit and ship with unit tests. The workflow-level `paths:` filter decides whether the workflow runs at all; `detect-changes` decides which jobs inside it are worth running. `!cancelled()` guards every gated job because `detect-changes` is skipped on `workflow_dispatch`. --- .github/workflows/csharp.yml | 78 ++++++ .github/workflows/python.yml | 75 ++++++ csharp/scripts/detect-code-changes.mjs | 267 ++++++++++++++++++++ csharp/scripts/detect-code-changes.test.mjs | 91 +++++++ python/scripts/detect_code_changes.py | 195 ++++++++++++++ python/tests/test_detect_code_changes.py | 79 ++++++ 6 files changed, 785 insertions(+) create mode 100644 csharp/scripts/detect-code-changes.mjs create mode 100644 csharp/scripts/detect-code-changes.test.mjs create mode 100644 python/scripts/detect_code_changes.py create mode 100644 python/tests/test_detect_code_changes.py diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index e0ae18f..4e98b25 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -38,6 +38,47 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true jobs: + # === CHANGE DETECTION === + # Template parity: the JavaScript and Rust pipelines gated their expensive + # jobs on what actually changed, while Python and C# ran everything for a + # documentation-only pull request. The workflow-level `paths:` filter only + # decides whether this workflow runs at all; this job decides which jobs + # inside it are worth running. + detect-changes: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: true + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + cs-changed: ${{ steps.changes.outputs.cs-changed }} + project-changed: ${{ steps.changes.outputs.project-changed }} + mjs-changed: ${{ steps.changes.outputs.mjs-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Detect changes + id: changes + working-directory: ./csharp + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/detect-code-changes.mjs + # Linting and formatting lint: timeout-minutes: 15 @@ -47,6 +88,21 @@ jobs: cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest + needs: [detect-changes] + # `!cancelled()` is required because detect-changes is skipped on + # workflow_dispatch; it still propagates a real workflow cancellation. + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.cs-changed == 'true' || + needs.detect-changes.outputs.project-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + }} steps: - uses: actions/checkout@v7 with: @@ -110,6 +166,21 @@ jobs: cancel-in-progress: true name: Test (.NET on ${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: [detect-changes] + # `!cancelled()` is required because detect-changes is skipped on + # workflow_dispatch; it still propagates a real workflow cancellation. + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.cs-changed == 'true' || + needs.detect-changes.outputs.project-changed == 'true' || + needs.detect-changes.outputs.mjs-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + }} strategy: fail-fast: false matrix: @@ -170,6 +241,13 @@ jobs: name: Build Package runs-on: ubuntu-latest needs: [lint, test] + # Skipped together with lint and test when nothing relevant changed. + if: >- + ${{ + !cancelled() && + needs.lint.result == 'success' && + needs.test.result == 'success' + }} steps: - uses: actions/checkout@v7 with: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index a2f5cea..bafbf1d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -36,6 +36,46 @@ jobs: # REQUIRED CI CHECKS - All must pass before release # These jobs ensure code quality and tests pass before any release + # === CHANGE DETECTION === + # Template parity: the JavaScript and Rust pipelines gated their expensive + # jobs on what actually changed, while Python and C# ran everything for a + # documentation-only pull request. The workflow-level `paths:` filter only + # decides whether this workflow runs at all; this job decides which jobs + # inside it are worth running. + detect-changes: + timeout-minutes: 10 + # Read-only check: a newer run supersedes this one. + concurrency: + group: check-${{ github.workflow }}-${{ github.ref }}-detect-changes + cancel-in-progress: true + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name != 'workflow_dispatch' + outputs: + py-changed: ${{ steps.changes.outputs.py-changed }} + toml-changed: ${{ steps.changes.outputs.toml-changed }} + docs-changed: ${{ steps.changes.outputs.docs-changed }} + workflow-changed: ${{ steps.changes.outputs.workflow-changed }} + any-code-changed: ${{ steps.changes.outputs.any-code-changed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.13' + + - name: Detect changes + id: changes + working-directory: ./python + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python scripts/detect_code_changes.py + # Linting and formatting lint: timeout-minutes: 15 @@ -45,6 +85,20 @@ jobs: cancel-in-progress: true name: Lint and Format Check runs-on: ubuntu-latest + needs: [detect-changes] + # `!cancelled()` is required because detect-changes is skipped on + # workflow_dispatch; it still propagates a real workflow cancellation. + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.py-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + }} steps: - uses: actions/checkout@v7 with: @@ -103,6 +157,20 @@ jobs: cancel-in-progress: true name: Test (Python 3.13 on ${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: [detect-changes] + # `!cancelled()` is required because detect-changes is skipped on + # workflow_dispatch; it still propagates a real workflow cancellation. + if: >- + ${{ + !cancelled() && ( + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.py-changed == 'true' || + needs.detect-changes.outputs.toml-changed == 'true' || + needs.detect-changes.outputs.docs-changed == 'true' || + needs.detect-changes.outputs.workflow-changed == 'true' + ) + }} strategy: fail-fast: false matrix: @@ -160,6 +228,13 @@ jobs: name: Build Package runs-on: ubuntu-latest needs: [lint, test] + # Skipped together with lint and test when nothing relevant changed. + if: >- + ${{ + !cancelled() && + needs.lint.result == 'success' && + needs.test.result == 'success' + }} steps: - uses: actions/checkout@v7 with: diff --git a/csharp/scripts/detect-code-changes.mjs b/csharp/scripts/detect-code-changes.mjs new file mode 100644 index 0000000..b2f828b --- /dev/null +++ b/csharp/scripts/detect-code-changes.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +/** + * Detect code changes for the C# CI/CD pipeline + * + * This script detects what types of files have changed between two commits + * and outputs the results for use in GitHub Actions workflow conditions. + * + * Key behavior: + * - For PRs: compares PR head against base branch + * - For pushes: compares HEAD against HEAD^ + * - Excludes certain folders and file types from "code changes" detection + * + * Excluded from code changes (don't require changesets): + * - Markdown files (*.md) in any folder + * - .changeset/ folder (changeset metadata) + * - docs/ folder (documentation) + * - experiments/ folder (experimental scripts) + * - examples/ folder (example scripts) + * + * Usage: + * node scripts/detect-code-changes.mjs + * + * Environment variables (set by GitHub Actions): + * - GITHUB_EVENT_NAME: 'pull_request' or 'push' + * - GITHUB_BASE_SHA: Base commit SHA for PR + * - GITHUB_HEAD_SHA: Head commit SHA for PR + * + * Outputs (written to GITHUB_OUTPUT): + * - cs-changed: 'true' if any .cs files changed + * - project-changed: 'true' if any .csproj/.sln/.props file changed + * - mjs-changed: 'true' if any .mjs files changed + * - docs-changed: 'true' if any .md files changed + * - workflow-changed: 'true' if any .github/workflows/ files changed + * - any-code-changed: 'true' if any code files changed (excludes docs, changesets, experiments, examples) + */ + +import { execSync } from 'child_process'; +import { appendFileSync } from 'fs'; +import { pathToFileURL } from 'url'; + +/** + * Execute a shell command and return trimmed output + * @param {string} command - The command to execute + * @returns {string} - The trimmed command output + */ +function exec(command) { + try { + return execSync(command, { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(error.message); + return ''; + } +} + +/** + * Write output to GitHub Actions output file + * @param {string} name - Output name + * @param {string} value - Output value + */ +function setOutput(name, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${name}=${value}\n`); + } + console.log(`${name}=${value}`); +} + +/** + * Get the list of changed files between two commits + * @returns {string[]} Array of changed file paths + */ +export function getChangedFiles() { + const eventName = process.env.GITHUB_EVENT_NAME || 'local'; + + if (eventName === 'pull_request') { + const baseSha = process.env.GITHUB_BASE_SHA; + const headSha = process.env.GITHUB_HEAD_SHA; + + if (baseSha && headSha) { + console.log(`Comparing PR: ${baseSha}...${headSha}`); + try { + // Ensure we have the base commit + try { + execSync(`git cat-file -e ${baseSha}`, { stdio: 'ignore' }); + } catch { + console.log('Base commit not available locally, attempting fetch...'); + execSync(`git fetch origin ${baseSha}`, { stdio: 'inherit' }); + } + const output = exec(`git diff --name-only ${baseSha} ${headSha}`); + return output ? output.split('\n').filter(Boolean) : []; + } catch (error) { + console.error(`Git diff failed: ${error.message}`); + } + } + } + + // For push events or fallback + console.log('Comparing HEAD^ to HEAD'); + try { + const output = exec('git diff --name-only HEAD^ HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } catch { + // If HEAD^ doesn't exist (first commit), list all files in HEAD + console.log('HEAD^ not available, listing all files in HEAD'); + const output = exec('git ls-tree --name-only -r HEAD'); + return output ? output.split('\n').filter(Boolean) : []; + } +} + +/** + * Path of the current working directory relative to the repository root. + * + * `git diff --name-only` always prints paths relative to the **repository + * root**, but this script runs with `working-directory: ./csharp` and compares + * those paths against package-relative prefixes such as `examples/`. In this + * monorepo the real paths are `csharp/examples/...`, so before issue #41 none of + * the exclusions ever matched and the project files could never be detected -- the + * same class of defect as issue #39. Resolving the prefix at run time keeps the + * script correct both here and in a single-package checkout, where the prefix + * is empty. + * + * @returns {string} The prefix, ending with `/`, or `''` at the repository root + */ +export function getPathPrefix() { + return exec('git rev-parse --show-prefix'); +} + +/** + * Re-express repository-root-relative paths as package-relative paths, + * dropping everything that lives outside this package. + * + * @param {string[]} changedFiles - Repository-root-relative paths + * @param {string} prefix - Package prefix such as `csharp/` + * @returns {string[]} Package-relative paths + */ +export function toPackagePaths(changedFiles, prefix) { + if (!prefix) { + return changedFiles; + } + return changedFiles + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +/** + * Check whether a repository-root-relative path is a workflow definition. + * + * @param {string} filePath - Repository-root-relative path + * @returns {boolean} True for files under `.github/workflows/` + */ +export function isWorkflowFile(filePath) { + return filePath.startsWith('.github/workflows/'); +} + +/** Project and solution files that describe how the C# code is built. */ +export const PROJECT_FILE_PATTERN = /\.(csproj|sln|props)$/; + +/** + * Check if a file should be excluded from code changes detection + * @param {string} filePath - The file path to check + * @returns {boolean} True if the file should be excluded + */ +export function isExcludedFromCodeChanges(filePath) { + // Exclude markdown files in any folder + if (filePath.endsWith('.md')) { + return true; + } + + // Exclude specific folders from code changes + const excludedFolders = ['.changeset/', 'docs/', 'experiments/', 'examples/']; + + for (const folder of excludedFolders) { + if (filePath.startsWith(folder)) { + return true; + } + } + + return false; +} + +/** + * Classify a set of repository-root-relative changed paths. + * + * Split out from `detectChanges` so the classification can be unit tested + * without a git repository. + * + * @param {string[]} changedFiles - Repository-root-relative changed paths + * @param {string} prefix - Package prefix such as `csharp/` + * @returns {{outputs: Record, packageFiles: string[], codeChangedFiles: string[]}} + */ +export function classifyChanges(changedFiles, prefix) { + const packageFiles = toPackagePaths(changedFiles, prefix); + const workflowFiles = changedFiles.filter(isWorkflowFile); + + // Code changes are judged on package-relative paths, so the documented + // `examples/`, `experiments/`, `docs/` and `.changeset/` exclusions apply. + const codeChangedFiles = packageFiles.filter( + (file) => !isExcludedFromCodeChanges(file) + ); + + // A workflow change still counts as a code change: it can alter how this + // package is built and published even when no package file moved. + const codePattern = /\.(cs|csproj|sln|props|mjs|json|yml|yaml)$/; + const codeChanged = + codeChangedFiles.some((file) => codePattern.test(file)) || + workflowFiles.length > 0; + + return { + packageFiles, + codeChangedFiles, + outputs: { + 'cs-changed': String(packageFiles.some((f) => f.endsWith('.cs'))), + 'project-changed': String( + packageFiles.some((f) => PROJECT_FILE_PATTERN.test(f)) + ), + 'mjs-changed': String(packageFiles.some((f) => f.endsWith('.mjs'))), + 'docs-changed': String(packageFiles.some((f) => f.endsWith('.md'))), + 'workflow-changed': String(workflowFiles.length > 0), + 'any-code-changed': String(codeChanged), + }, + }; +} + +/** + * Main function to detect changes + */ +export function detectChanges() { + console.log('Detecting file changes for CI/CD...\n'); + + const changedFiles = getChangedFiles(); + const prefix = getPathPrefix(); + + console.log(`Package prefix: ${prefix || '(repository root)'}`); + console.log('Changed files:'); + if (changedFiles.length === 0) { + console.log(' (none)'); + } else { + changedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + const { codeChangedFiles, outputs } = classifyChanges(changedFiles, prefix); + + console.log('Files considered as code changes:'); + if (codeChangedFiles.length === 0) { + console.log(' (none)'); + } else { + codeChangedFiles.forEach((file) => console.log(` ${file}`)); + } + console.log(''); + + for (const [name, value] of Object.entries(outputs)) { + setOutput(name, value); + } + + console.log('\nChange detection completed.'); +} + +// Run the detection unless this module was imported by a test. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + detectChanges(); +} diff --git a/csharp/scripts/detect-code-changes.test.mjs b/csharp/scripts/detect-code-changes.test.mjs new file mode 100644 index 0000000..2a64d39 --- /dev/null +++ b/csharp/scripts/detect-code-changes.test.mjs @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + classifyChanges, + isExcludedFromCodeChanges, + isWorkflowFile, + toPackagePaths, +} from './detect-code-changes.mjs'; + +// Regression guard for the monorepo path bug found while solving issue #41 +// (same class as issue #39): `git diff --name-only` yields repository-root +// paths, so every package-relative comparison has to strip the `csharp/` prefix +// first. +test('toPackagePaths strips the package prefix and drops foreign packages', () => { + const files = [ + 'csharp/src/Lino.Objects.Codec/Codec.cs', + 'rust/src/lib.rs', + '.github/workflows/csharp.yml', + ]; + assert.deepEqual(toPackagePaths(files, 'csharp/'), [ + 'src/Lino.Objects.Codec/Codec.cs', + ]); +}); + +test('toPackagePaths is a no-op at the repository root', () => { + const files = ['src/Codec.cs']; + assert.deepEqual(toPackagePaths(files, ''), files); +}); + +test('an examples-only pull request is not a code change', () => { + const { outputs } = classifyChanges(['csharp/examples/Demo.cs'], 'csharp/'); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['cs-changed'], 'true'); +}); + +test('a changeset-only pull request is not a code change', () => { + const { outputs } = classifyChanges( + ['csharp/.changeset/happy-pandas-run.md'], + 'csharp/' + ); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['docs-changed'], 'true'); +}); + +test('the project file is detected through the package prefix', () => { + const { outputs } = classifyChanges( + ['csharp/src/Lino.Objects.Codec/Lino.Objects.Codec.csproj'], + 'csharp/' + ); + assert.equal(outputs['project-changed'], 'true'); + assert.equal(outputs['any-code-changed'], 'true'); +}); + +test('a source change is a code change', () => { + const { outputs } = classifyChanges( + ['csharp/src/Lino.Objects.Codec/Codec.cs'], + 'csharp/' + ); + assert.equal(outputs['any-code-changed'], 'true'); + assert.equal(outputs['cs-changed'], 'true'); +}); + +test('a workflow change counts as a code change', () => { + const { outputs } = classifyChanges( + ['.github/workflows/csharp.yml'], + 'csharp/' + ); + assert.equal(outputs['workflow-changed'], 'true'); + assert.equal(outputs['any-code-changed'], 'true'); +}); + +test('changes in another package are ignored', () => { + const { outputs } = classifyChanges( + ['python/src/lino_objects_codec/codec.py'], + 'csharp/' + ); + assert.equal(outputs['any-code-changed'], 'false'); + assert.equal(outputs['docs-changed'], 'false'); +}); + +test('isWorkflowFile only matches repository-root workflow paths', () => { + assert.equal(isWorkflowFile('.github/workflows/csharp.yml'), true); + assert.equal(isWorkflowFile('csharp/.github/workflows/csharp.yml'), false); +}); + +test('markdown is excluded from code changes anywhere in the package', () => { + assert.equal(isExcludedFromCodeChanges('docs/guide.md'), true); + assert.equal(isExcludedFromCodeChanges('src/README.md'), true); + assert.equal(isExcludedFromCodeChanges('src/Codec.cs'), false); +}); diff --git a/python/scripts/detect_code_changes.py b/python/scripts/detect_code_changes.py new file mode 100644 index 0000000..aca4e54 --- /dev/null +++ b/python/scripts/detect_code_changes.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Detect code changes for the Python CI/CD pipeline. + +This script decides whether a push or pull request touched anything that needs +the expensive jobs to run. It mirrors ``scripts/detect_code_changes.py`` in +``link-foundation/python-ai-driven-development-pipeline-template`` and the +JavaScript, Rust and C# siblings in this repository. + +Key behaviour: + +- For pull requests: compares the pull request head against its base. +- For pushes: compares ``HEAD`` against ``HEAD^``. +- Paths are judged *relative to this package*. ``git diff --name-only`` prints + repository-root paths, so in this monorepo the real paths are + ``python/examples/demo.py``; without stripping the ``python/`` prefix the + documented exclusions below would never match. That mistake is what issue #39 + was about, and the JavaScript and Rust scripts carried it until issue #41. + +Excluded from code changes (they never need a changelog fragment): + +- Markdown files (``*.md``) anywhere. +- ``changelog.d/`` (changelog fragments) +- ``docs/`` (documentation) +- ``experiments/`` (experimental scripts) +- ``examples/`` (example scripts) + +Usage:: + + python scripts/detect_code_changes.py + +Environment variables (set by GitHub Actions): + +- ``GITHUB_EVENT_NAME``: ``pull_request`` or ``push`` +- ``GITHUB_BASE_SHA``: base commit SHA for a pull request +- ``GITHUB_HEAD_SHA``: head commit SHA for a pull request + +Outputs (appended to ``GITHUB_OUTPUT``): + +- ``py-changed``: any ``.py`` file in this package changed +- ``toml-changed``: any ``.toml`` file in this package changed +- ``docs-changed``: any ``.md`` file in this package changed +- ``workflow-changed``: any ``.github/workflows/`` file changed +- ``any-code-changed``: any code file changed, exclusions applied +""" + +from __future__ import annotations + +import os +import subprocess + +EXCLUDED_FOLDERS = ( + "changelog.d/", + "docs/", + "examples/", + "experiments/", +) +CODE_EXTENSIONS = (".py", ".toml", ".yml", ".yaml", ".cfg") +WORKFLOW_PREFIX = ".github/workflows/" + + +def exec_command(command: list[str]) -> str: + """Run a command and return its trimmed stdout, or an empty string.""" + try: + result = subprocess.run( # noqa: S603 - fixed argument lists only + command, + capture_output=True, + text=True, + check=True, + ) + except (OSError, subprocess.CalledProcessError) as error: + print(f"Error executing command: {' '.join(command)}") + print(error) + return "" + return result.stdout.strip() + + +def set_output(name: str, value: str) -> None: + """Append a GitHub Actions job output and echo it into the log.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + print(f"{name}={value}") + + +def get_path_prefix() -> str: + """Return the working directory relative to the repository root. + + ``python/`` in this monorepo, and ``""`` in a single-package checkout, where + every path is already package-relative. + """ + return exec_command(["git", "rev-parse", "--show-prefix"]) + + +def get_changed_files() -> list[str]: + """Return the repository-root-relative paths changed by this event.""" + event_name = os.environ.get("GITHUB_EVENT_NAME", "local") + + if event_name == "pull_request": + base_sha = os.environ.get("GITHUB_BASE_SHA") + head_sha = os.environ.get("GITHUB_HEAD_SHA") + if base_sha and head_sha: + print(f"Comparing PR: {base_sha}...{head_sha}") + if not exec_command(["git", "cat-file", "-t", base_sha]): + print("Base commit not available locally, attempting fetch...") + exec_command(["git", "fetch", "origin", base_sha]) + output = exec_command(["git", "diff", "--name-only", base_sha, head_sha]) + return [f for f in output.split("\n") if f] + + print("Comparing HEAD^ to HEAD") + output = exec_command(["git", "diff", "--name-only", "HEAD^", "HEAD"]) + if output: + return [f for f in output.split("\n") if f] + + # First commit in the repository: there is no HEAD^ to compare against. + print("HEAD^ not available, listing all files in HEAD") + output = exec_command(["git", "ls-tree", "--name-only", "-r", "HEAD"]) + return [f for f in output.split("\n") if f] + + +def to_package_paths(changed_files: list[str], prefix: str) -> list[str]: + """Re-express root-relative paths as package-relative, dropping the rest.""" + if not prefix: + return list(changed_files) + return [f.removeprefix(prefix) for f in changed_files if f.startswith(prefix)] + + +def is_workflow_file(file_path: str) -> bool: + """Return True for a repository-root workflow definition.""" + return file_path.startswith(WORKFLOW_PREFIX) + + +def is_excluded_from_code_changes(file_path: str) -> bool: + """Return True when a *package-relative* path is not a code change.""" + if file_path.endswith(".md"): + return True + return file_path.startswith(EXCLUDED_FOLDERS) + + +def classify_changes(changed_files: list[str], prefix: str) -> tuple[dict[str, str], list[str]]: + """Classify changed paths, returning the outputs and the code files.""" + package_files = to_package_paths(changed_files, prefix) + workflow_files = [f for f in changed_files if is_workflow_file(f)] + + code_changed_files = [f for f in package_files if not is_excluded_from_code_changes(f)] + # A workflow change counts as a code change: it can alter how this package + # is built and published even when no package file moved. + code_changed = bool(workflow_files) or any( + f.endswith(CODE_EXTENSIONS) for f in code_changed_files + ) + + outputs = { + "py-changed": str(any(f.endswith(".py") for f in package_files)).lower(), + "toml-changed": str(any(f.endswith(".toml") for f in package_files)).lower(), + "docs-changed": str(any(f.endswith(".md") for f in package_files)).lower(), + "workflow-changed": str(bool(workflow_files)).lower(), + "any-code-changed": str(code_changed).lower(), + } + return outputs, code_changed_files + + +def detect_changes() -> None: + """Detect the changes of the current event and publish them as outputs.""" + print("Detecting file changes for CI/CD...\n") + + changed_files = get_changed_files() + prefix = get_path_prefix() + + print(f"Package prefix: {prefix or '(repository root)'}") + print("Changed files:") + if not changed_files: + print(" (none)") + else: + for file in changed_files: + print(f" {file}") + print() + + outputs, code_changed_files = classify_changes(changed_files, prefix) + + print("Files considered as code changes:") + if not code_changed_files: + print(" (none)") + else: + for file in code_changed_files: + print(f" {file}") + print() + + for name, value in outputs.items(): + set_output(name, value) + + print("\nChange detection completed.") + + +if __name__ == "__main__": + detect_changes() diff --git a/python/tests/test_detect_code_changes.py b/python/tests/test_detect_code_changes.py new file mode 100644 index 0000000..fa57187 --- /dev/null +++ b/python/tests/test_detect_code_changes.py @@ -0,0 +1,79 @@ +"""Tests for ``scripts/detect_code_changes.py``. + +The prefix handling is a regression guard: ``git diff --name-only`` yields +repository-root paths, so every package-relative comparison has to strip the +``python/`` prefix first. Getting that wrong is what issue #39 was about, and +the same defect was found in the JavaScript and Rust scripts while solving +issue #41. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from detect_code_changes import ( # noqa: E402 + classify_changes, + is_excluded_from_code_changes, + is_workflow_file, + to_package_paths, +) + + +def test_to_package_paths_strips_prefix_and_drops_other_packages() -> None: + files = ["python/src/codec.py", "rust/src/lib.rs", ".github/workflows/python.yml"] + assert to_package_paths(files, "python/") == ["src/codec.py"] + + +def test_to_package_paths_is_a_no_op_at_the_repository_root() -> None: + assert to_package_paths(["src/codec.py"], "") == ["src/codec.py"] + + +def test_examples_only_pull_request_is_not_a_code_change() -> None: + outputs, code_files = classify_changes(["python/examples/demo.py"], "python/") + assert outputs["any-code-changed"] == "false" + assert outputs["py-changed"] == "true" + assert code_files == [] + + +def test_fragment_only_pull_request_is_not_a_code_change() -> None: + outputs, _ = classify_changes(["python/changelog.d/20260820_fix.md"], "python/") + assert outputs["any-code-changed"] == "false" + assert outputs["docs-changed"] == "true" + + +def test_pyproject_change_is_a_code_change() -> None: + outputs, _ = classify_changes(["python/pyproject.toml"], "python/") + assert outputs["toml-changed"] == "true" + assert outputs["any-code-changed"] == "true" + + +def test_source_change_is_a_code_change() -> None: + outputs, _ = classify_changes(["python/src/lino_objects_codec/codec.py"], "python/") + assert outputs["py-changed"] == "true" + assert outputs["any-code-changed"] == "true" + + +def test_workflow_change_counts_as_a_code_change() -> None: + outputs, _ = classify_changes([".github/workflows/python.yml"], "python/") + assert outputs["workflow-changed"] == "true" + assert outputs["any-code-changed"] == "true" + + +def test_changes_in_another_package_are_ignored() -> None: + outputs, _ = classify_changes(["rust/Cargo.toml"], "python/") + assert outputs["toml-changed"] == "false" + assert outputs["any-code-changed"] == "false" + + +def test_is_workflow_file_only_matches_repository_root_paths() -> None: + assert is_workflow_file(".github/workflows/python.yml") is True + assert is_workflow_file("python/.github/workflows/python.yml") is False + + +def test_markdown_is_excluded_anywhere_in_the_package() -> None: + assert is_excluded_from_code_changes("docs/guide.md") is True + assert is_excluded_from_code_changes("src/README.md") is True + assert is_excluded_from_code_changes("src/codec.py") is False From f82dbc56686f161e48037ac67c88ab40e933fa79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:54:34 +0000 Subject: [PATCH 25/29] docs(dev-log): record what was executed for issue #41 --- ...20260820_083000_issue_41_cicd_hardening.md | 3 + dev/log/issues/41/pulls/42/ANALYSIS.md | 99 +++++++++++++++++++ ...20260820_083000_issue_41_cicd_hardening.md | 5 + ...20260820_083000_issue_41_cicd_hardening.md | 4 + ...20260820_083000_issue_41_cicd_hardening.md | 7 ++ 5 files changed, 118 insertions(+) diff --git a/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md b/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md index e1b6304..1ee9d01 100644 --- a/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md +++ b/csharp/.changeset/20260820_083000_issue_41_cicd_hardening.md @@ -8,3 +8,6 @@ page renders the project documentation. Part of the CI/CD clean-up in [issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41), which also makes the C# changeset check able to fail, enforces the 1500-line file limit and finally runs the `csharp/scripts` unit tests in CI. + +C# also gains the `detect-changes` job the other pipelines already had, so a +documentation-only pull request no longer runs the full .NET matrix. diff --git a/dev/log/issues/41/pulls/42/ANALYSIS.md b/dev/log/issues/41/pulls/42/ANALYSIS.md index 05e5750..355e0da 100644 --- a/dev/log/issues/41/pulls/42/ANALYSIS.md +++ b/dev/log/issues/41/pulls/42/ANALYSIS.md @@ -367,3 +367,102 @@ than a guess: - Rust readback — `CRATES_WAIT_VERBOSE=1`. All three default to off, so normal runs stay quiet. + +--- + +## 9. Execution record + +What was actually done, in the order it was committed. Every entry was verified +locally before it was committed; the verification command is named where it is not +obvious. + +| Commit | Plan step | Change | +| --- | --- | --- | +| `cde9620` | 1 | Evidence archive: workflow files, run logs, template copies. | +| `d913208` | — | This analysis. | +| `ef20bf6`, `e46dd0b` | 2 | `wait-for-registry.mjs` + tests; called from both C# release paths. | +| `a797ada`, `097056a` | 3 | Python release helpers linted; PyPI readback widened, failure explained. | +| `6b1e962` | 4 | crates.io publish verification + shared-script tests. | +| `fa867ad` | 5, 6 | Every action to its current Node 24 major; Codecov inputs fixed. | +| `b86f595` | 7 | `PackageReadmeFile` for NuGet; PEP 639 licence metadata for Python. | +| `bf90ac2` | 8 | `npm audit fix` — 7 advisories (2 moderate, 5 high) cleared. | +| `4ff3717` | — | JavaScript codec split to clear the 3 standing ESLint warnings. | +| `9149a3f` | 9 | Top-level `permissions`, `timeout-minutes` everywhere, reader/writer concurrency split, `!cancelled()`. | +| `1d8dc18` | 9 | actionlint over all workflows. | +| `d3738cf`, `d28060b` | 10 | `security.yml` and `links.yml`, then the link gate made honest (see §9.2). | +| `cee793c` | 9 | Fresh-merge simulation (`scripts/simulate-fresh-merge.sh`) in all 15 pull-request check jobs. | +| `1c3b6e5` | 9 | C# file-size limit; `csharp/scripts` unit tests finally executed in CI. | +| `f1a50c9` | 10 | gitleaks secret scanning (tree + full history). | +| `b049685` | — | Python and C# release-note checks made able to fail (see §9.3). | +| `67ea0a8` | 9 | Python tested on three operating systems, not one. | +| `f6f143d` | — | Change detection fixed to use package-relative paths (see §9.1). | +| `967430d` | 9 | `detect-changes` added for Python and C#, closing the last template gap. | +| `51f7ed4` | 12 | Release notes in all four languages, so the fixed pipeline actually publishes. | + +### 9.1 A false positive that only the fix made visible + +`js/scripts/detect-code-changes.mjs` and its Rust twin ran with +`working-directory: ./js` but compared `git diff --name-only` output — which is +**repository-root**-relative — against package-relative prefixes such as +`examples/`. In this monorepo the real path is `js/examples/demo.mjs`, so: + +- the documented `examples/`, `experiments/`, `docs/` and `.changeset/` exclusions + never matched, and an examples-only pull request was reported as a code change + and asked for a changeset it does not need — a false positive; +- `package-changed` could never become `true` — a false negative; +- Rust's `toml-changed` fired on any `.toml` anywhere, including + `python/pyproject.toml`. + +This is the same defect class as issue #39. `experiments/detect-code-changes-monorepo-paths.sh` +reproduces the original behaviour end to end in a scratch repository; the unit +tests added alongside the fix pin each case down. The prefix is now resolved at +run time with `git rev-parse --show-prefix`, which keeps the scripts correct in a +single-package checkout too, where the prefix is empty. The templates are **not** +affected: they are single-package repositories where the prefix is empty, so this +is not an upstream report. + +### 9.2 Two false positives found by the new gates themselves + +Adding a check is only useful if the check is honest, so both new gates were run +locally before being trusted: + +- **lychee** failed with five `[403] https://www.npmjs.com/package/lino-objects-codec`. + Verified with `curl` that npmjs.com returns 403 to every non-browser client, with + and without a browser `User-Agent`; the links resolve fine in a browser. Recorded + in `.lycheeignore` with that reasoning rather than by dropping the badges. Result: + `107 OK, 0 Errors, 5 Excluded`. +- **gitleaks** reported seven `generic-api-key` findings, all of them + `key: macOS-cargo-` lines inside the archived CI logs in this directory — + `actions/cache` keys, which are content hashes of `Cargo.lock`. Fixed with a + documented path allowlist in `.gitleaks.toml` for the evidence directories only, + not by weakening the rule. Result: 0 findings across the tree and all 146 commits. + +### 9.3 Checks that were structurally incapable of failing + +The Python `changelog` job and the C# `changeset-check` job both ended in +`::warning::` followed by `exit 0`, and both counted files sitting in a directory +rather than files in the pull request's diff — so they were green for a pull +request that changed source with no release note, and green for one whose only +"fragment" was left over from an earlier unreleased pull request. Both are now +real scripts (`csharp/scripts/check-changeset.mjs`, +`python/scripts/validate_changeset.py`) with unit tests, deciding from +`git diff --name-only --relative origin/...HEAD`. Both also interpolated +`github.base_ref` directly into a shell command; branch names are +attacker-controlled and are now passed through the environment. + +### 9.4 actionlint and the `queue` key + +actionlint reports `unexpected key "queue" for "concurrency" section` on the +release jobs. `concurrency.queue` is documented by GitHub but is not yet in +actionlint's bundled schema, so the lint step passes +`-ignore 'unexpected key "queue" for "concurrency" section'`. This is a tool +limitation, recorded here so the ignore is removed rather than inherited once +actionlint catches up. + +### 9.5 Parity gate + +`scripts/check-language-parity.mjs` fails on this pull request with +"Changed: JavaScript, C# / Missing a matching change: Python, Rust" because the +work is CI/CD infrastructure rather than a codec feature, and the four languages +needed different amounts of it. The gate's own documented escape hatch applies: +the pull request body carries `[skip-parity]` with this reasoning. diff --git a/js/.changeset/20260820_083000_issue_41_cicd_hardening.md b/js/.changeset/20260820_083000_issue_41_cicd_hardening.md index 8f4663f..0b00475 100644 --- a/js/.changeset/20260820_083000_issue_41_cicd_hardening.md +++ b/js/.changeset/20260820_083000_issue_41_cicd_hardening.md @@ -9,3 +9,8 @@ run are gone. Behaviour and the public API are unchanged and the full 244-test suite is untouched. Part of the CI/CD clean-up in [issue #41](https://github.com/link-foundation/lino-objects-codec/issues/41), which also fixes the seven `npm audit` advisories in the dev dependency tree. + +The same change also fixes the JavaScript change-detection script, which +compared repository-root paths (`js/examples/demo.mjs`) against +package-relative prefixes (`examples/`), so an examples-only pull request was +reported as a code change and `package-changed` could never be true. diff --git a/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md b/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md index 64bd3a0..a27e241 100644 --- a/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md +++ b/python/changelog.d/20260820_083000_issue_41_cicd_hardening.md @@ -12,3 +12,7 @@ 0, so a pull request could change `python/src` with no release note and still show a green check, and it counted leftover fragments from earlier unreleased pull requests. The requirement is now decided from the pull-request diff. + +- Python is tested on macOS and Windows as well as Linux, and gains the + `detect-changes` job the JavaScript and Rust pipelines already had, so a + documentation-only pull request no longer runs the full matrix. diff --git a/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md b/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md index 13028da..bf904ed 100644 --- a/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md +++ b/rust/changelog.d/20260820_083000_issue_41_cicd_hardening.md @@ -11,3 +11,10 @@ bump: patch `cargo publish` can no longer be cancelled mid-flight, and pull requests are now validated against a fresh merge with the base branch rather than a stale merge preview. No change to the crate's code or public API. + +### Fixed + +- The change-detection script compared repository-root paths + (`rust/examples/demo.rs`) against package-relative prefixes (`examples/`), so + the documented exclusions never applied and `toml-changed` fired on any + `.toml` in the repository, including `python/pyproject.toml`. From f9721dc5ae01a849fbeb42294c87055ec1f9620f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 08:57:58 +0000 Subject: [PATCH 26/29] docs(dev-log): correct the upstream findings and link the filed reports Reading the templates in full disproved two of the three hypothesised upstream defects: tpl-rust does verify the crates.io publish (wait-for-crate.rs, called from release.yml:632) and tpl-python's PyPI smoke test retries 6 x 20 s and fails the release. Both claims are withdrawn in place rather than deleted. The defect that is real is the one this pull request just fixed downstream: the JavaScript and Rust templates match their change-detection exclusion lists against repository-root paths, so nothing is excluded in the multi-language layout their own path helpers support. Reported with reproductions: link-foundation/js-ai-driven-development-pipeline-template#141 link-foundation/rust-ai-driven-development-pipeline-template#139 --- dev/log/issues/41/pulls/42/ANALYSIS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dev/log/issues/41/pulls/42/ANALYSIS.md b/dev/log/issues/41/pulls/42/ANALYSIS.md index 355e0da..325a11d 100644 --- a/dev/log/issues/41/pulls/42/ANALYSIS.md +++ b/dev/log/issues/41/pulls/42/ANALYSIS.md @@ -328,8 +328,14 @@ readback), `dotnet nuget verify` (verifies signatures, not registry presence). | Defect | Template(s) affected | Action | | --- | --- | --- | | Node 20 actions | verified current in the templates — **not** an upstream defect | none | -| No crates.io post-publish verification | Rust template has the same blind spot as §3.4 | report upstream | -| Publish verification budget hard-coded inline instead of using the shared script | Python template's PyPI readback is shorter than its own install smoke test | report upstream | +| No crates.io post-publish verification | **withdrawn.** `tpl-rust/scripts/wait-for-crate.rs` exists and `release.yml:632` calls it; the blind spot in §3.4 is this repository's, not the template's | none | +| Publish verification budget | **withdrawn.** `tpl-python/scripts/smoke_test_published_package.py` retries 6 x 20 s and fails the release job if the version never appears; that is a defensible budget for PyPI | none | +| Change detection ignores the multi-language layout ([js#141](https://github.com/link-foundation/js-ai-driven-development-pipeline-template/issues/141), [rust#139](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/issues/139)) | JavaScript and Rust templates: `detect-code-changes` matches exclusion prefixes such as `examples/` against repository-root paths, so in the `js/` / `rust/` layout that `js-paths.mjs` and `rust-paths.rs` explicitly support, nothing is ever excluded. The C# template already solves this with `prefixCsharpRoot()` and the Python template with `removeprefix("python/")` | report upstream | + +The first two rows were written from the run evidence before the templates were +read in full; reading them disproved both claims, and filing them would have been +noise. They are kept here rather than deleted because a withdrawn hypothesis is +part of the record. --- From ebbd1d963388402e64326cab2208fe924c9505ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 09:02:41 +0000 Subject: [PATCH 27/29] ci: quote $GITHUB_OUTPUT redirections flagged by shellcheck The pushed branch's Lint Workflows job failed with nine SC2086 reports for unquoted `>> $GITHUB_OUTPUT`. Local actionlint had exited 0 because actionlint only runs shellcheck when the binary is on PATH and skips that analysis silently otherwise -- a green local lint that was itself a false negative. Quotes all 10 such redirections across csharp.yml, python.yml and rust.yml, not only the 4 lines the linter pointed at. Verified with `actionlint -shellcheck `, which now exits 0. --- .github/workflows/csharp.yml | 8 ++++---- .github/workflows/python.yml | 10 +++++----- .github/workflows/rust.yml | 2 +- dev/log/issues/41/pulls/42/ANALYSIS.md | 25 ++++++++++++++++++++++++- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 4e98b25..5fe5381 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -370,8 +370,8 @@ jobs: CURRENT_VERSION=$(grep -Po '(?<=)[^<]*' src/Lino.Objects.Codec/Lino.Objects.Codec.csproj) PACKAGE_ID=$(grep -Po '(?<=)[^<]*' src/Lino.Objects.Codec/Lino.Objects.Codec.csproj) PACKAGE_ID_LC=$(echo "$PACKAGE_ID" | tr '[:upper:]' '[:lower:]') - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "package_id=$PACKAGE_ID" >> $GITHUB_OUTPUT + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "package_id=$PACKAGE_ID" >> "$GITHUB_OUTPUT" # Decide based on the registry, not on the local git tag. NuGet's flat-container index # returns 200 for an existing package id and 404 otherwise; the version-specific .nuspec @@ -381,10 +381,10 @@ jobs: echo "NuGet HTTP status for ${PACKAGE_ID}@${CURRENT_VERSION}: ${STATUS}" if [ "$STATUS" = "200" ]; then echo "Version ${PACKAGE_ID}@${CURRENT_VERSION} already on NuGet, skipping publish" - echo "should_release=false" >> $GITHUB_OUTPUT + echo "should_release=false" >> "$GITHUB_OUTPUT" else echo "Version ${PACKAGE_ID}@${CURRENT_VERSION} is NOT on NuGet, will publish" - echo "should_release=true" >> $GITHUB_OUTPUT + echo "should_release=true" >> "$GITHUB_OUTPUT" fi - name: Download artifacts diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index bafbf1d..d88304b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -366,8 +366,8 @@ jobs: # Get current version from pyproject.toml CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml) PACKAGE_NAME=$(grep -Po '(?<=^name = ")[^"]*' pyproject.toml | head -1) - echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT - echo "package_name=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" # Decide based on the registry, not on the local git tag. PyPI returns 200 for an # existing version's JSON metadata and 404 otherwise. See @@ -376,10 +376,10 @@ jobs: echo "PyPI HTTP status for ${PACKAGE_NAME}@${CURRENT_VERSION}: ${STATUS}" if [ "$STATUS" = "200" ]; then echo "Version ${PACKAGE_NAME}@${CURRENT_VERSION} already on PyPI, skipping publish" - echo "should_release=false" >> $GITHUB_OUTPUT + echo "should_release=false" >> "$GITHUB_OUTPUT" else echo "Version ${PACKAGE_NAME}@${CURRENT_VERSION} is NOT on PyPI, will publish" - echo "should_release=true" >> $GITHUB_OUTPUT + echo "should_release=true" >> "$GITHUB_OUTPUT" fi - name: Download artifacts @@ -540,7 +540,7 @@ jobs: working-directory: ./python run: | PACKAGE_NAME=$(grep -Po '(?<=^name = ")[^"]*' pyproject.toml | head -1) - echo "package_name=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT" - name: PyPI publish preflight if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4f698f2..afa1a4b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -409,7 +409,7 @@ jobs: working-directory: ./rust run: | VERSION=$(grep -Po '(?<=^version = ")[^"]*' Cargo.toml) - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build release if: steps.release_check.outputs.should_release == 'true' diff --git a/dev/log/issues/41/pulls/42/ANALYSIS.md b/dev/log/issues/41/pulls/42/ANALYSIS.md index 325a11d..c6af178 100644 --- a/dev/log/issues/41/pulls/42/ANALYSIS.md +++ b/dev/log/issues/41/pulls/42/ANALYSIS.md @@ -465,7 +465,30 @@ actionlint's bundled schema, so the lint step passes limitation, recorded here so the ignore is removed rather than inherited once actionlint catches up. -### 9.5 Parity gate +### 9.5 shellcheck: unquoted `$GITHUB_OUTPUT` (found only by CI) + +The first pipeline run of the pushed branch failed the `Lint Workflows` job with +nine `SC2086 Double quote to prevent globbing and word splitting` reports across +`csharp.yml:368`, `python.yml:365`, `python.yml:541` and `rust.yml:410` -- every +one an unquoted `>> $GITHUB_OUTPUT`. + +This is worth recording because **the local verification could not have caught +it**: actionlint only runs shellcheck when a `shellcheck` binary is on `PATH`, +and it silently skips that analysis when there is none. Locally there was none, +so `actionlint` exited 0 while the same version on the runner -- where +shellcheck is preinstalled -- exited 1. A green local lint was itself a false +negative, which is precisely the class of defect issue #41 is about. + +The fix quotes every such redirection repository-wide (10 occurrences in three +workflows), not only the four the linter happened to point at. Reproducing the +CI result locally now requires passing the binary explicitly: + +``` +actionlint -shellcheck /path/to/shellcheck \ + -ignore 'unexpected key "queue" for "concurrency" section' +``` + +### 9.6 Parity gate `scripts/check-language-parity.mjs` fails on this pull request with "Changed: JavaScript, C# / Missing a matching change: Python, Rust" because the From e72ed2b3544ed92a58ec9e2af182e46052858811 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 09:04:41 +0000 Subject: [PATCH 28/29] ci(security): replace unavailable dependency review with a NuGet audit The dependency-review job failed with "Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled" -- a repository setting a pull request cannot change, leaving a check that could only ever fail. It is removed rather than marked continue-on-error, since an ignored failure is the same false negative issue #41 is about. Its only unique coverage was NuGet, which is replaced by `dotnet list package --vulnerable --include-transitive`, so all four ecosystems are audited again. That command exits 0 even when it finds advisories, so the job inspects the report instead of the exit code. Both paths verified locally. --- .github/workflows/security.yml | 41 ++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1738ca0..f173bd4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -63,25 +63,42 @@ jobs: - name: Analyze uses: github/codeql-action/analyze@v4 - dependency-review: - name: Dependency Review - if: github.event_name == 'pull_request' + # `actions/dependency-review-action` was tried here first and fails with + # "Dependency review is not supported on this repository. Please ensure that + # Dependency graph is enabled." That is a repository/organisation setting, not + # something a pull request can change, and a job that can only ever fail is + # worse than no job. The four ecosystem audits below cover the same ground + # with tools that work today; see dev/log/issues/41/pulls/42/ANALYSIS.md 9.7. + nuget-audit: + name: Audit NuGet dependencies runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 concurrency: - group: check-${{ github.workflow }}-${{ github.ref }}-dependency-review + group: check-${{ github.workflow }}-${{ github.ref }}-nuget-audit cancel-in-progress: true - permissions: - contents: read - pull-requests: write steps: - uses: actions/checkout@v7 - - name: Review dependency changes - uses: actions/dependency-review-action@v5 + - name: Setup .NET + uses: actions/setup-dotnet@v6 with: - fail-on-severity: high - comment-summary-in-pr: on-failure + dotnet-version: '8.0.x' + + # `dotnet list package --vulnerable` exits 0 even when it finds + # advisories, so the report is inspected rather than the exit code. + # `--include-transitive` matters: most advisories arrive indirectly. + - name: Audit NuGet dependencies + working-directory: ./csharp + run: | + dotnet restore + dotnet list package --vulnerable --include-transitive \ + > nuget-audit.txt 2>&1 + cat nuget-audit.txt + if grep -qiE '^\s+> ' nuget-audit.txt; then + echo "::error::Vulnerable NuGet packages found (see report above)" + exit 1 + fi + echo "No vulnerable NuGet packages found." npm-audit: name: Audit npm lock From dde3a7cf26cbee14382906edd5ba09172a854f2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 09:04:56 +0000 Subject: [PATCH 29/29] docs(dev-log): record why dependency review was replaced by a NuGet audit --- dev/log/issues/41/pulls/42/ANALYSIS.md | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/dev/log/issues/41/pulls/42/ANALYSIS.md b/dev/log/issues/41/pulls/42/ANALYSIS.md index c6af178..7468252 100644 --- a/dev/log/issues/41/pulls/42/ANALYSIS.md +++ b/dev/log/issues/41/pulls/42/ANALYSIS.md @@ -488,7 +488,44 @@ actionlint -shellcheck /path/to/shellcheck \ -ignore 'unexpected key "queue" for "concurrency" section' ``` -### 9.6 Parity gate +### 9.7 Dependency review is unavailable, NuGet was uncovered + +The first security run reported: + +``` +##[error]Dependency review is not supported on this repository. +Please ensure that Dependency graph is enabled +``` + +`actions/dependency-review-action` needs GitHub's Dependency graph switched on +for the repository. `gh api repos/link-foundation/lino-objects-codec` confirms +the repository is public but reports no dependency-graph entry under +`security_and_analysis`, and enabling it is a repository/organisation setting +that a pull request cannot carry. Leaving the job in place would have created a +check that can *only* fail -- the mirror image of the checks in 9.3 that could +only pass, and equally useless. + +It was removed rather than marked `continue-on-error`, because a job whose +failure is ignored is exactly the false negative issue #41 is about. + +Removing it did expose a real gap: `npm audit`, `cargo audit` and `pip-audit` +cover three of the four ecosystems here, and dependency review had been the only +thing that would have looked at **NuGet**. It is replaced by a native +`dotnet list package --vulnerable --include-transitive` job, so all four +languages are now audited by tools that work today. + +One detail is worth recording: `dotnet list package --vulnerable` **exits 0 even +when it finds advisories**, so trusting its exit code would have produced +another silently-passing check. The job greps the report instead. Both paths +were verified locally -- the real project reports "no vulnerable packages", and +a synthetic report containing a `> Newtonsoft.Json ... High` row is detected. + +**Follow-up for a repository admin** (not blocking this pull request): enabling +Dependency graph, Dependabot alerts and secret-scanning push protection at +https://github.com/link-foundation/lino-objects-codec/settings/security_analysis +would allow the dependency-review job to be restored on top of these audits. + +### 9.8 Parity gate `scripts/check-language-parity.mjs` fails on this pull request with "Changed: JavaScript, C# / Missing a matching change: Python, Rust" because the