testguard-cli 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. testguard_cli-0.9.0/.github/scripts/sync-release-version.mjs +195 -0
  2. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/workflows/auto-merge.yml +8 -1
  3. testguard_cli-0.9.0/.github/workflows/release.yml +336 -0
  4. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/workflows/scheduled-release.yml +58 -13
  5. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/CHANGELOG.md +206 -0
  6. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/CONTRIBUTING.md +8 -0
  7. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/PKG-INFO +36 -5
  8. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/README.md +35 -4
  9. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/action.yml +1 -1
  10. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/REQUIREMENTS.md +5 -5
  11. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/package-lock.json +169 -168
  12. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/package.json +2 -2
  13. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/packaging/gitlab/testguard.gitlab-ci.yml +2 -2
  14. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/packaging/homebrew/testguard.rb +7 -5
  15. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/pyproject.toml +1 -1
  16. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/cli.mjs +13 -4
  17. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/baseline.mjs +24 -3
  18. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/init/init.mjs +15 -2
  19. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/contention.mjs +20 -1
  20. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/replay/replay.mjs +88 -7
  21. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/scaffold/producers.mjs +16 -5
  22. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/scaffold/producers.python.mjs +8 -3
  23. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/scaffold/scaffold.mjs +25 -3
  24. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/baseline.test.mjs +23 -0
  25. testguard_cli-0.9.0/test/calibration.test.mjs +158 -0
  26. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/contention.test.mjs +35 -5
  27. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/init.test.mjs +55 -0
  28. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe-error.test.mjs +37 -0
  29. testguard_cli-0.9.0/test/release-sync.test.mjs +318 -0
  30. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/replay.test.mjs +4 -1
  31. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/scaffold.python.test.mjs +25 -0
  32. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/scaffold.test.mjs +91 -1
  33. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/testguard.claims.json +381 -5
  34. testguard_cli-0.9.0/testguard.cost-budget.json +82 -0
  35. testguard_cli-0.8.0/.github/scripts/sync-release-version.mjs +0 -50
  36. testguard_cli-0.8.0/.github/workflows/release.yml +0 -179
  37. testguard_cli-0.8.0/test/calibration.test.mjs +0 -73
  38. testguard_cli-0.8.0/test/release-sync.test.mjs +0 -118
  39. testguard_cli-0.8.0/testguard.cost-budget.json +0 -37
  40. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.agents/skills/testguard/SKILL.md +0 -0
  41. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.codex/hooks.json +0 -0
  42. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.docguard.json +0 -0
  43. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.docguardignore +0 -0
  44. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.gitattributes +0 -0
  45. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  46. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  47. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  48. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/dependabot.yml +0 -0
  49. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/scripts/build-one-pager.mjs +0 -0
  50. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/scripts/check-cost-budget.mjs +0 -0
  51. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/scripts/check-python-fixture.mjs +0 -0
  52. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/scripts/install-smoke.mjs +0 -0
  53. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/workflows/ci.yml +0 -0
  54. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.github/workflows/supply-chain.yml +0 -0
  55. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.gitignore +0 -0
  56. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.npmignore +0 -0
  57. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.npmrc +0 -0
  58. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.pre-commit-config.yaml +0 -0
  59. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/.pre-commit-hooks.yaml +0 -0
  60. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/AGENTS.md +0 -0
  61. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/CLAUDE.md +0 -0
  62. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  63. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/DRIFT-LOG.md +0 -0
  64. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/LICENSE +0 -0
  65. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/PRIVACY.md +0 -0
  66. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/SECURITY.md +0 -0
  67. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/SUPPORT.md +0 -0
  68. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/bench/README.md +0 -0
  69. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/cli/testguard.mjs +0 -0
  70. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs/testguard-explained.html +0 -0
  71. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs/testguard-explained.pdf +0 -0
  72. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/ARCHITECTURE.md +0 -0
  73. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/DATA-MODEL.md +0 -0
  74. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/ENVIRONMENT.md +0 -0
  75. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/SECURITY.md +0 -0
  76. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/docs-canonical/TEST-SPEC.md +0 -0
  77. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/.gitignore +0 -0
  78. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/README.md +0 -0
  79. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/expected.json +0 -0
  80. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/package.json +0 -0
  81. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/src/export.mjs +0 -0
  82. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/src/redact.mjs +0 -0
  83. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/test/export-mocked.test.mjs +0 -0
  84. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/test/flaky.test.mjs +0 -0
  85. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/test/redact.test.mjs +0 -0
  86. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/testguard.claims.json +0 -0
  87. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer/vitest.config.mjs +0 -0
  88. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/.gitignore +0 -0
  89. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/README.md +0 -0
  90. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/expected.json +0 -0
  91. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/jest.config.js +0 -0
  92. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/package.json +0 -0
  93. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/src/export.js +0 -0
  94. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/src/redact.js +0 -0
  95. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/test/export-mocked.test.js +0 -0
  96. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/test/flaky.test.js +0 -0
  97. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/test/redact.test.js +0 -0
  98. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-jest/testguard.claims.json +0 -0
  99. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/.gitignore +0 -0
  100. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/README.md +0 -0
  101. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/e2e/flaky.spec.mjs +0 -0
  102. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/e2e/toggle.spec.mjs +0 -0
  103. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/expected.json +0 -0
  104. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/package-lock.json +0 -0
  105. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/package.json +0 -0
  106. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/playwright.config.mjs +0 -0
  107. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/src/toggle.mjs +0 -0
  108. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/test/toggle.test.mjs +0 -0
  109. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/test-results/.last-run.json +0 -0
  110. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/testguard.claims.json +0 -0
  111. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-playwright/vitest.config.mjs +0 -0
  112. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/.gitignore +0 -0
  113. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/README.md +0 -0
  114. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/demo/__init__.py +0 -0
  115. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/demo/export.py +0 -0
  116. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/demo/orphan.py +0 -0
  117. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/demo/pipeline.py +0 -0
  118. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/demo/redact.py +0 -0
  119. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/expected.json +0 -0
  120. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/testguard.claims.json +0 -0
  121. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/tests/test_export_mocked.py +0 -0
  122. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/tests/test_flaky.py +0 -0
  123. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/tests/test_patched.py +0 -0
  124. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/fixtures/known-answer-python/tests/test_redact.py +0 -0
  125. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/GATE-SEMANTICS.md +0 -0
  126. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/README.md +0 -0
  127. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/baseline.json +0 -0
  128. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/brief.json +0 -0
  129. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/calibration-websec.json +0 -0
  130. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/calibration.json +0 -0
  131. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/claims-methods.json +0 -0
  132. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/claims.json +0 -0
  133. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence-disturbed-run.json +0 -0
  134. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence-negative-control.json +0 -0
  135. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence-provisional.json +0 -0
  136. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence-python.json +0 -0
  137. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence-scan-method.json +0 -0
  138. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/evidence.json +0 -0
  139. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/gate.json +0 -0
  140. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/ignore.json +0 -0
  141. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/replay.json +0 -0
  142. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/examples/status.json +0 -0
  143. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/baseline.bad-fingerprint-key.json +0 -0
  144. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/baseline.zero-count.json +0 -0
  145. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/brief.text-without-heading.json +0 -0
  146. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/brief.unknown-verdict-key.json +0 -0
  147. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.backoff-cell-not-wilson.json +0 -0
  148. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.breakdown-does-not-sum-to-n.json +0 -0
  149. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.ci-not-wilson.json +0 -0
  150. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.compound-key-arity-mismatch.json +0 -0
  151. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.fallback-without-basis.json +0 -0
  152. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.p-not-positives-over-n.json +0 -0
  153. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.p-outside-ci.json +0 -0
  154. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/calibration.positives-exceed-n.json +0 -0
  155. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.absolute-path.json +0 -0
  156. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.duplicate-fault-id.json +0 -0
  157. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.fault-injection-without-a-substitution.json +0 -0
  158. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.method-detail-on-a-specified-method.json +0 -0
  159. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.missing-provenance.json +0 -0
  160. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.noop-fault.json +0 -0
  161. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.occurrence-exceeds-hits.json +0 -0
  162. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/claims.parent-traversal.json +0 -0
  163. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.annotated-signal-without-reason.json +0 -0
  164. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.attribute-signal-not-a-defender.json +0 -0
  165. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.attribute-signal-without-attributes.json +0 -0
  166. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.byrunner-file-not-resolved.json +0 -0
  167. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.contention-without-runners.json +0 -0
  168. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.discovered-defender-mocks-subject.json +0 -0
  169. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.fault-injection-without-its-evidence.json +0 -0
  170. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.flaky-defender-without-flakiness.json +0 -0
  171. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.ignored-dirty-on-snapshot.json +0 -0
  172. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.killed-by-non-assertion.json +0 -0
  173. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.killed-on-red-baseline.json +0 -0
  174. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.killed-without-n-runs.json +0 -0
  175. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.method-detail-on-an-injecting-run.json +0 -0
  176. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.negative-control-not-reached-but-survived.json +0 -0
  177. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.negative-control-reached-on-a-kill.json +0 -0
  178. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.probe-error-without-message.json +0 -0
  179. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.provisional-flag-on-confirmed-run.json +0 -0
  180. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.rank-extra-property.json +0 -0
  181. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.restore-skipped-in-place.json +0 -0
  182. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.single-run-without-provisional.json +0 -0
  183. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.target-not-imported-but-killed.json +0 -0
  184. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.undeclared-killers-without-reason.json +0 -0
  185. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.unknown-verdict.json +0 -0
  186. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.unverifiable-without-reason.json +0 -0
  187. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/evidence.wrong-fingerprint.json +0 -0
  188. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/gate.counts-disagree.json +0 -0
  189. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/gate.exit-zero-with-uncovered.json +0 -0
  190. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/gate.ignore-cover-without-pattern.json +0 -0
  191. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/ignore.missing-reason.json +0 -0
  192. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/ignore.short-reason.json +0 -0
  193. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/replay.blind-with-a-failing-run.json +0 -0
  194. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/replay.duplicate-patch-id.json +0 -0
  195. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/status.claim-without-file.json +0 -0
  196. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/status.clean-with-new-findings.json +0 -0
  197. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/status.unclaimed-hidden-behind-unproven.json +0 -0
  198. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/invalid/status.write-test-without-target.json +0 -0
  199. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/conformance/schemas.test.mjs +0 -0
  200. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/lib/fingerprint.mjs +0 -0
  201. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/lib/validate.mjs +0 -0
  202. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/lib/wilson.mjs +0 -0
  203. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/baseline.schema.json +0 -0
  204. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/brief.schema.json +0 -0
  205. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/calibration.schema.json +0 -0
  206. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/claims.schema.json +0 -0
  207. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/common.schema.json +0 -0
  208. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/evidence.schema.json +0 -0
  209. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/gate.schema.json +0 -0
  210. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/ignore.schema.json +0 -0
  211. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/replay.schema.json +0 -0
  212. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/spec/schemas/status.schema.json +0 -0
  213. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/admit/admit.mjs +0 -0
  214. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/baseline/baseline.mjs +0 -0
  215. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/brief/brief.mjs +0 -0
  216. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/claims/annotations.mjs +0 -0
  217. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/claims/load.mjs +0 -0
  218. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/claims/removed.mjs +0 -0
  219. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/admit.mjs +0 -0
  220. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/brief.mjs +0 -0
  221. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/claims.mjs +0 -0
  222. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/gate.mjs +0 -0
  223. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/init.mjs +0 -0
  224. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/mcp.mjs +0 -0
  225. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/probe.mjs +0 -0
  226. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/replay.mjs +0 -0
  227. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/scaffold.mjs +0 -0
  228. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/commands/status.mjs +0 -0
  229. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/evidence/writer.mjs +0 -0
  230. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/gate/changed.mjs +0 -0
  231. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/git.mjs +0 -0
  232. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/init/templates/SKILL.md +0 -0
  233. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/mcp/server.mjs +0 -0
  234. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/mcp/tools.mjs +0 -0
  235. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/attribution.mjs +0 -0
  236. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/classify.mjs +0 -0
  237. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/cost.mjs +0 -0
  238. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/discover.mjs +0 -0
  239. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/independence.mjs +0 -0
  240. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/inject.mjs +0 -0
  241. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/mocks.mjs +0 -0
  242. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/probe.mjs +0 -0
  243. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/progress.mjs +0 -0
  244. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/pyimports.mjs +0 -0
  245. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/rank.mjs +0 -0
  246. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/index.mjs +0 -0
  247. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/jest.mjs +0 -0
  248. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/playwright.mjs +0 -0
  249. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/python/_testguard_pytest_plugin.py +0 -0
  250. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/python/_testguard_report.py +0 -0
  251. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/python/_testguard_unittest_main.py +0 -0
  252. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/python.mjs +0 -0
  253. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/shared.mjs +0 -0
  254. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/runners/vitest.mjs +0 -0
  255. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/probe/worktree.mjs +0 -0
  256. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/render.mjs +0 -0
  257. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/replay/label.mjs +0 -0
  258. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/status/status.mjs +0 -0
  259. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/util/glob.mjs +0 -0
  260. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/src/util/hash.mjs +0 -0
  261. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/admit.test.mjs +0 -0
  262. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/annotations.test.mjs +0 -0
  263. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/attribution.test.mjs +0 -0
  264. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/brief.test.mjs +0 -0
  265. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/claims-since.test.mjs +0 -0
  266. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/classify.test.mjs +0 -0
  267. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/cost.test.mjs +0 -0
  268. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/discover.test.mjs +0 -0
  269. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/gate.test.mjs +0 -0
  270. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/gitlab-template.test.mjs +0 -0
  271. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/glob.test.mjs +0 -0
  272. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/independence.test.mjs +0 -0
  273. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/inject.test.mjs +0 -0
  274. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/mcp.test.mjs +0 -0
  275. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/mocks.test.mjs +0 -0
  276. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/negative-control.test.mjs +0 -0
  277. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe-preconditions.test.mjs +0 -0
  278. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe.fixture.test.mjs +0 -0
  279. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe.jest.test.mjs +0 -0
  280. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe.playwright.test.mjs +0 -0
  281. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/probe.python.test.mjs +0 -0
  282. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/progress.test.mjs +0 -0
  283. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/pyimports.test.mjs +0 -0
  284. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/rank-aliases.test.mjs +0 -0
  285. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/runner-argv.test.mjs +0 -0
  286. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/runner-command.test.mjs +0 -0
  287. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/runner-playwright.test.mjs +0 -0
  288. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/runner-python.test.mjs +0 -0
  289. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/runner-vitest.test.mjs +0 -0
  290. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/samples/playwright-report.json +0 -0
  291. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/samples/python-report-collect-error.json +0 -0
  292. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/samples/python-report.json +0 -0
  293. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/snapshot.test.mjs +0 -0
  294. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/status.test.mjs +0 -0
  295. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/worktree.test.mjs +0 -0
  296. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/test/writer.test.mjs +0 -0
  297. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/testguard.ignore.json +0 -0
  298. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/testguard_cli/__init__.py +0 -0
  299. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/testguard_cli/wrapper.py +0 -0
  300. {testguard_cli-0.8.0 → testguard_cli-0.9.0}/vitest.config.mjs +0 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * package.json is the single source of truth for the version. Every other
4
+ * surface that carries it is synced from there:
5
+ * - pyproject.toml `version = "x.y.z"`
6
+ * - action.yml default of the `version` input
7
+ * - packaging/homebrew/*.rb the tarball URL; the `sha256` is set by `--sha256`
8
+ * from the PUBLISHED tarball (release.yml, after npm)
9
+ * - packaging/gitlab/*.yml the include URL tag and the `version` input default
10
+ * - README.md the GitHub Action reference AND the GitLab include URL
11
+ *
12
+ * A file may carry more than one form, and each needs its own entry: README
13
+ * holds both the `raccioly/testguard@vX.Y.Z` action reference and the
14
+ * `testguard/vX.Y.Z/packaging` template URL. Only the first was listed, so
15
+ * v0.6.0 shipped with both README copies of the GitLab URL left at v0.5.0
16
+ * while `--check` reported "all version surfaces at 0.6.0" — a surface this
17
+ * script was never told to look at cannot drift in its eyes.
18
+ *
19
+ * The Homebrew `sha256` is the same lesson one step further: the URL was a
20
+ * surface, the hash beside it was a comment telling a human to run curl. It
21
+ * carried 385d69f9… unchanged across v0.6.0, v0.7.0 and v0.8.0 — a hash
22
+ * matching none of them. So the hash is now a surface too, with three modes:
23
+ *
24
+ * --check offline: every version surface at package.json's version,
25
+ * the formula's sha256 is 64 hex and not the known-stale
26
+ * placeholder. Used by release.yml's test job (before the
27
+ * tag, when the tarball cannot exist yet) and prepublishOnly.
28
+ * --check --online additionally downloads the tarball the formula names and
29
+ * requires its sha256 to match. Used by release.yml's
30
+ * detect-version (is the formula still owed?) and by the
31
+ * sha job as a post-condition before it opens the PR.
32
+ * --sha256 waits for the tarball to become downloadable (bounded by
33
+ * --wait), computes its sha256 and writes it into the
34
+ * formula — only when the value changes. Exit 1 on timeout
35
+ * or on a 200 that is not a gzip tarball: a hashed error
36
+ * page would poison the formula, and "never optimistic".
37
+ *
38
+ * The hash is always computed from the REGISTRY tarball — what Homebrew will
39
+ * download — never from a local `npm pack`: a provenance-signed publish can
40
+ * differ byte for byte from the pack output.
41
+ *
42
+ * --list-surfaces print every file this script may write, one per line. The
43
+ * release workflows allow-list exactly these (plus
44
+ * package.json, package-lock.json and CHANGELOG.md, which
45
+ * the bump writes directly), so a surface added above can
46
+ * never again be rejected as an "unexpected file in the
47
+ * release diff" — which is what stopped every weekly
48
+ * release after the GitLab template became a surface.
49
+ * --registry <url> registry base (default https://registry.npmjs.org); the
50
+ * tests point it at a local server so `npm test` is offline.
51
+ * --wait <s> how long --sha256 keeps polling (default 900).
52
+ * --interval <s> seconds between polls (default 15).
53
+ * --timeout <s> per-request timeout (default 60): a registry that accepts the
54
+ * connection and never answers is a failed attempt, not a hang.
55
+ */
56
+ import { createHash } from 'node:crypto';
57
+ import { readFileSync, writeFileSync } from 'node:fs';
58
+ import { resolve } from 'node:path';
59
+
60
+ const root = resolve(process.argv[1], '..', '..', '..');
61
+ const argv = process.argv.slice(2);
62
+ const flag = (name) => argv.includes(name);
63
+ const option = (name, fallback) => {
64
+ const i = argv.indexOf(name);
65
+ return i === -1 ? fallback : argv[i + 1];
66
+ };
67
+ const check = flag('--check');
68
+ const listSurfaces = flag('--list-surfaces');
69
+ const online = flag('--online');
70
+ const setSha = flag('--sha256');
71
+ const registry = (option('--registry', 'https://registry.npmjs.org')).replace(/\/+$/, '');
72
+ const waitSeconds = Number(option('--wait', '900'));
73
+ const intervalSeconds = Number(option('--interval', '15'));
74
+ const timeoutSeconds = Number(option('--timeout', '60'));
75
+ if (!Number.isFinite(waitSeconds) || waitSeconds < 0) throw new Error(`--wait must be a non-negative number of seconds, got ${option('--wait')}`);
76
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds < 0) throw new Error(`--interval must be a non-negative number of seconds, got ${option('--interval')}`);
77
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) throw new Error(`--timeout must be a positive number of seconds, got ${option('--timeout')}`);
78
+
79
+ const version = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version;
80
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`package.json version is not a stable semver: ${version}`);
81
+
82
+ const FORMULA = 'packaging/homebrew/testguard.rb';
83
+ const formulaPath = resolve(root, FORMULA);
84
+ /** The hash the formula carried, unchanged, from v0.6.0 through v0.8.0. It matches no published tarball. */
85
+ const KNOWN_STALE_SHA256 = '385d69f9d3c153b934d9c1cb6a2c754eb9b221b0a8c0ba8b805858384d2d4678';
86
+ const SHA_LINE = /^(\s*sha256 ")([^"]*)(")/m;
87
+ const TARBALL = `testguard-cli-${version}.tgz`;
88
+ const tarballUrl = `${registry}/testguard-cli/-/${TARBALL}`;
89
+
90
+ const surfaces = [
91
+ ['pyproject.toml', /^version = "[^"]+"/m, `version = "${version}"`],
92
+ ['action.yml', /(\n version:\n description: [^\n]*\n required: false\n default: ')[^']*(')/, `$1${version}$2`],
93
+ [FORMULA, /testguard-cli-\d+\.\d+\.\d+\.tgz/g, TARBALL],
94
+ ['README.md', /raccioly\/testguard@v\d+\.\d+\.\d+/g, `raccioly/testguard@v${version}`],
95
+ ['README.md', /testguard\/v\d+\.\d+\.\d+\/packaging/g, `testguard/v${version}/packaging`],
96
+ ['packaging/gitlab/testguard.gitlab-ci.yml', /testguard\/v\d+\.\d+\.\d+\/packaging/g, `testguard/v${version}/packaging`],
97
+ ['packaging/gitlab/testguard.gitlab-ci.yml', /(\n version:\n description: [^\n]*\n default: ")\d+\.\d+\.\d+(")/, `$1${version}$2`],
98
+ ];
99
+
100
+ if (listSurfaces) {
101
+ // Derived from the same table the writes use, so the two cannot disagree.
102
+ for (const rel of [...new Set(surfaces.map(([rel]) => rel))]) console.log(rel);
103
+ process.exit(0);
104
+ }
105
+
106
+ /** The formula's current `sha256 "…"` value, or a throw when the line is missing. */
107
+ function formulaSha256() {
108
+ const m = SHA_LINE.exec(readFileSync(formulaPath, 'utf8'));
109
+ if (!m) throw new Error(`${FORMULA}: sha256 line not found`);
110
+ return m[2];
111
+ }
112
+
113
+ /**
114
+ * One GET of the tarball. Returns `{ status, sha256 }`; `sha256` is set only
115
+ * for a 200 whose body starts with the gzip magic (1f 8b) — a registry error
116
+ * page with a 200 status is treated as "not a tarball", never hashed.
117
+ */
118
+ async function fetchTarball() {
119
+ const res = await fetch(tarballUrl, { redirect: 'follow', signal: AbortSignal.timeout(timeoutSeconds * 1000) });
120
+ if (res.status !== 200) return { status: res.status };
121
+ const body = Buffer.from(await res.arrayBuffer());
122
+ if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b) return { status: 200, notTarball: true, bytes: body.length };
123
+ return { status: 200, sha256: createHash('sha256').update(body).digest('hex'), bytes: body.length };
124
+ }
125
+
126
+ const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
127
+
128
+ /**
129
+ * Polls until the tarball downloads as a gzip, or the wait runs out. Every
130
+ * non-200 and every network error counts as "not yet": the registry answers
131
+ * `PUT 202` to a publish and can take minutes to serve the file.
132
+ */
133
+ async function waitForTarball() {
134
+ const deadline = Date.now() + waitSeconds * 1000;
135
+ let attempt = 0;
136
+ for (;;) {
137
+ attempt++;
138
+ let result;
139
+ try {
140
+ result = await fetchTarball();
141
+ } catch (e) {
142
+ result = { status: 0, error: e.message };
143
+ }
144
+ if (result.sha256) return result;
145
+ const why = result.notTarball ? `200 but not a gzip tarball (${result.bytes} bytes)` : result.error ? `network error: ${result.error}` : `HTTP ${result.status}`;
146
+ if (Date.now() >= deadline) throw new Error(`${tarballUrl}: not downloadable after ${attempt} attempt(s) over ${waitSeconds}s — last: ${why}`);
147
+ console.error(`${TARBALL}: ${why}; retrying in ${intervalSeconds}s (attempt ${attempt})`);
148
+ await sleep(intervalSeconds);
149
+ }
150
+ }
151
+
152
+ if (setSha) {
153
+ const { sha256, bytes } = await waitForTarball();
154
+ const before = readFileSync(formulaPath, 'utf8');
155
+ const current = formulaSha256();
156
+ if (current === sha256) {
157
+ console.log(`${FORMULA}: sha256 unchanged (${sha256}, ${bytes} bytes)`);
158
+ } else {
159
+ writeFileSync(formulaPath, before.replace(SHA_LINE, `$1${sha256}$3`));
160
+ if (formulaSha256() !== sha256) throw new Error(`${FORMULA}: sha256 line did not take the new value`);
161
+ console.log(`${FORMULA}: sha256 ${current} -> ${sha256} (${bytes} bytes from ${tarballUrl})`);
162
+ }
163
+ process.exit(0);
164
+ }
165
+
166
+ let drift = 0;
167
+ for (const [rel, pattern, replacement] of surfaces) {
168
+ const path = resolve(root, rel);
169
+ const before = readFileSync(path, 'utf8');
170
+ if (!pattern.test(before)) throw new Error(`${rel}: version pattern not found`);
171
+ const after = before.replace(pattern, replacement);
172
+ if (after === before) continue;
173
+ drift++;
174
+ if (check) console.error(`${rel}: out of sync with package.json ${version}`);
175
+ else { writeFileSync(path, after); console.log(`${rel}: synced to ${version}`); }
176
+ }
177
+
178
+ if (check) {
179
+ const sha = formulaSha256();
180
+ if (!/^[0-9a-f]{64}$/.test(sha)) { drift++; console.error(`${FORMULA}: sha256 is not 64 hex characters: "${sha}"`); }
181
+ else if (sha === KNOWN_STALE_SHA256) { drift++; console.error(`${FORMULA}: sha256 is the known-stale placeholder ${sha.slice(0, 8)}… (matches no published tarball); run --sha256 after publishing`); }
182
+ if (online) {
183
+ let result;
184
+ try {
185
+ result = await fetchTarball();
186
+ } catch (e) {
187
+ result = { status: 0, error: e.message };
188
+ }
189
+ if (!result.sha256) { drift++; console.error(`${FORMULA}: tarball not downloadable — ${tarballUrl}: ${result.error ?? (result.notTarball ? '200 but not a gzip tarball' : `HTTP ${result.status}`)}`); }
190
+ else if (result.sha256 !== sha) { drift++; console.error(`${FORMULA}: sha256 ${sha} does not match the published tarball ${result.sha256} (${tarballUrl})`); }
191
+ else console.log(`${FORMULA}: sha256 matches ${tarballUrl}`);
192
+ }
193
+ }
194
+ if (check && drift) process.exit(1);
195
+ if (check) console.log(`all version surfaces at ${version}`);
@@ -46,7 +46,14 @@ jobs:
46
46
 
47
47
  const { data: files } = await github.rest.pulls.listFiles({ owner, repo, pull_number: num, per_page: 300 });
48
48
  const paths = files.map((f) => f.filename);
49
- const RELEASE_SURFACES = new Set(['package.json', 'package-lock.json', 'pyproject.toml', 'CHANGELOG.md', 'action.yml', 'packaging/homebrew/testguard.rb', 'README.md']);
49
+ // Deliberately spelled out, not derived: this is the gate that merges
50
+ // without human review, so what a bot may touch stays readable here
51
+ // and is not computed from code the PR itself could change. Keep it
52
+ // in step with `sync-release-version.mjs --list-surfaces` plus the
53
+ // three files the bump writes — test/release-sync.test.mjs fails if
54
+ // a surface is missing, which is how the GitLab template's absence
55
+ // (it would have held every release PR for review) was found.
56
+ const RELEASE_SURFACES = new Set(['package.json', 'package-lock.json', 'pyproject.toml', 'CHANGELOG.md', 'action.yml', 'packaging/homebrew/testguard.rb', 'README.md', 'packaging/gitlab/testguard.gitlab-ci.yml']);
50
57
 
51
58
  if (author === 'dependabot[bot]') {
52
59
  const m = /from (\d+)\.(\d+)\.\S+ to (\d+)\.(\d+)\./.exec(pr.title || '');
@@ -0,0 +1,336 @@
1
+ name: Auto Release — Tag, GitHub Release, npm, PyPI
2
+
3
+ # TAG-DRIVEN and idempotent: a version needs releasing iff no tag v<version>
4
+ # exists. Re-runs never double-publish. No long-lived secrets anywhere —
5
+ # npm and PyPI both authenticate with OIDC Trusted Publishing.
6
+ #
7
+ # ONE-TIME SETUP (maintainer, cannot be automated):
8
+ # npm: the first `npm publish` of testguard-cli is manual (Trusted Publishing
9
+ # is configured on an existing package). Then on npmjs.com →
10
+ # testguard-cli → Settings → Trusted Publisher: raccioly/testguard,
11
+ # workflow release.yml. Every later release is OIDC.
12
+ # PyPI: pypi.org → Your account → Publishing → add a PENDING publisher for
13
+ # project testguard-cli: owner raccioly, repo testguard, workflow
14
+ # release.yml, environment pypi. The first release creates the project.
15
+
16
+ on:
17
+ workflow_dispatch: # manual recovery
18
+ schedule:
19
+ - cron: '17 * * * *' # hourly sweep: publishes any merged version bump the push event missed
20
+ push:
21
+ branches: [main]
22
+
23
+ permissions:
24
+ contents: write # tags + releases
25
+
26
+ concurrency:
27
+ group: testguard-release
28
+ cancel-in-progress: false
29
+
30
+ jobs:
31
+ detect-version:
32
+ runs-on: ubuntu-latest
33
+ outputs:
34
+ version: ${{ steps.check.outputs.version }}
35
+ changed: ${{ steps.check.outputs.changed }}
36
+ tag_missing: ${{ steps.check.outputs.tag_missing }}
37
+ npm_missing: ${{ steps.check.outputs.npm_missing }}
38
+ pypi_missing: ${{ steps.check.outputs.pypi_missing }}
39
+ brew_missing: ${{ steps.check.outputs.brew_missing }}
40
+ steps:
41
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
42
+ with:
43
+ fetch-depth: 1
44
+ - name: What does this version still need?
45
+ id: check
46
+ # Each surface is gated on ITS OWN registry, not on the tag. A tag
47
+ # whose npm or PyPI publish failed (e.g. Trusted Publishing not yet
48
+ # configured) is retried by simply re-running this workflow.
49
+ #
50
+ # The Homebrew formula's sha256 is a surface too, gated on the registry
51
+ # tarball it names: `--check --online` downloads it and compares. It is
52
+ # owed (brew_missing=true) until npm serves the tarball AND the formula
53
+ # on main carries its hash — so a failed sha job is retried by the
54
+ # hourly sweep exactly like a failed publish. It does not set `changed`:
55
+ # only tag/npm/PyPI need the test job and the publish jobs.
56
+ run: |
57
+ CURRENT=$(node -p "require('./package.json').version")
58
+ TAG="v${CURRENT}"
59
+ echo "version=$CURRENT" >> "$GITHUB_OUTPUT"
60
+ if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "refs/tags/$TAG$"; then TAGM=false; else TAGM=true; fi
61
+ if npm view "testguard-cli@${CURRENT}" version >/dev/null 2>&1; then NPMM=false; else NPMM=true; fi
62
+ CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/testguard-cli/${CURRENT}/json")
63
+ if [ "$CODE" = "200" ]; then PYPIM=false; else PYPIM=true; fi
64
+ if node .github/scripts/sync-release-version.mjs --check --online; then BREWM=false; else BREWM=true; fi
65
+ echo "tag_missing=$TAGM" >> "$GITHUB_OUTPUT"
66
+ echo "npm_missing=$NPMM" >> "$GITHUB_OUTPUT"
67
+ echo "pypi_missing=$PYPIM" >> "$GITHUB_OUTPUT"
68
+ echo "brew_missing=$BREWM" >> "$GITHUB_OUTPUT"
69
+ if [ "$TAGM" = true ] || [ "$NPMM" = true ] || [ "$PYPIM" = true ]; then
70
+ echo "changed=true" >> "$GITHUB_OUTPUT"
71
+ echo "✅ $TAG — tag missing: $TAGM · npm missing: $NPMM · PyPI missing: $PYPIM · Homebrew sha256 owed: $BREWM"
72
+ elif [ "$BREWM" = true ]; then
73
+ echo "changed=false" >> "$GITHUB_OUTPUT"
74
+ echo "✅ $TAG is published everywhere; only the Homebrew sha256 is owed"
75
+ else
76
+ echo "changed=false" >> "$GITHUB_OUTPUT"
77
+ echo "⏭️ $TAG is fully released everywhere — nothing to do"
78
+ fi
79
+
80
+ test:
81
+ needs: detect-version
82
+ if: needs.detect-version.outputs.changed == 'true'
83
+ runs-on: ubuntu-latest
84
+ steps:
85
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
86
+ with:
87
+ fetch-depth: 0
88
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
89
+ with:
90
+ node-version: 24
91
+ - run: npm ci
92
+ - run: npm test
93
+ - name: Install smoke — the tarball must run with production deps only
94
+ run: npm run test:install
95
+ - name: Self-probe must be fully defended before anything ships
96
+ run: node cli/testguard.mjs probe . --budget 180000 --out "$RUNNER_TEMP/self-evidence.json" --quiet --cost
97
+ - name: Version surfaces are in sync
98
+ # Offline on purpose. Before the tag the tarball for this version
99
+ # cannot exist, so `--online` would say nothing in the normal path and
100
+ # would DEADLOCK the recovery path: npm published, the sha job failed,
101
+ # the formula still stale — an online check here fails and the job
102
+ # that fixes the formula never runs. The online comparison lives in
103
+ # detect-version (is it owed?) and in homebrew-sha (did it take?).
104
+ run: node .github/scripts/sync-release-version.mjs --check
105
+
106
+ create-release:
107
+ needs: [detect-version, test]
108
+ if: needs.detect-version.outputs.changed == 'true'
109
+ # Runs even when the tag exists: every step inside is idempotent.
110
+ runs-on: ubuntu-latest
111
+ outputs:
112
+ tag: v${{ needs.detect-version.outputs.version }}
113
+ steps:
114
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
115
+ with:
116
+ fetch-depth: 0
117
+ - name: Create and push tag
118
+ env:
119
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
120
+ run: |
121
+ set -o pipefail
122
+ TAG="v${{ needs.detect-version.outputs.version }}"
123
+ if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
124
+ echo "✅ Tag $TAG already exists on remote"
125
+ exit 0
126
+ fi
127
+ git config user.name "github-actions[bot]"
128
+ git config user.email "github-actions[bot]@users.noreply.github.com"
129
+ git tag -a "$TAG" -m "Release $TAG"
130
+ if git push origin "$TAG" 2>/tmp/tag-err; then
131
+ echo "✅ Pushed $TAG"
132
+ exit 0
133
+ fi
134
+ cat /tmp/tag-err
135
+ # A GitHub App may not PUSH a ref whose .github/workflows differ from
136
+ # the CURRENT default branch. That is not a property of this release:
137
+ # it becomes true the moment any workflow change merges to main while
138
+ # the release is in flight, which is exactly how v0.8.1 lost its tag
139
+ # ("refusing to allow a GitHub App to create or update workflow
140
+ # .github/workflows/release.yml without `workflows` permission")
141
+ # seven minutes after an unrelated workflow PR merged.
142
+ #
143
+ # So fall back to the API. It adds a ref to a commit that is ALREADY
144
+ # in the repository and creates no file content, so the restriction
145
+ # does not apply. The push is tried first on purpose: it is the path
146
+ # every previous release took, and this fallback only has to cover
147
+ # the case that currently fails outright.
148
+ if ! grep -q 'workflows. permission' /tmp/tag-err; then
149
+ echo "::error::git push of $TAG failed for a reason other than the workflow-permission restriction — see above."
150
+ exit 1
151
+ fi
152
+ echo "::warning::Pushing $TAG was refused because main's workflows moved during this release; creating the tag through the API instead."
153
+ TAG_SHA=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/tags" -f tag="$TAG" -f message="Release $TAG" -f object="$GITHUB_SHA" -f type=commit --jq .sha) || {
154
+ echo "::error::Could not create the tag object for $TAG. Create it by hand: git tag -a $TAG $GITHUB_SHA -m 'Release $TAG' && git push origin $TAG"; exit 1; }
155
+ gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/tags/$TAG" -f sha="$TAG_SHA" --jq .ref || {
156
+ echo "::error::Could not create refs/tags/$TAG from tag object $TAG_SHA. Create it by hand: git tag -a $TAG $GITHUB_SHA -m 'Release $TAG' && git push origin $TAG"; exit 1; }
157
+ echo "✅ Created $TAG through the API at $GITHUB_SHA"
158
+ - name: Release notes from CHANGELOG
159
+ run: |
160
+ VERSION="${{ needs.detect-version.outputs.version }}"
161
+ NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
162
+ [ -n "$NOTES" ] || NOTES="Release v${VERSION} — see CHANGELOG.md."
163
+ printf '%s\n' "$NOTES" > /tmp/release-notes.md
164
+ - name: Create GitHub Release
165
+ env:
166
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167
+ run: |
168
+ TAG="v${{ needs.detect-version.outputs.version }}"
169
+ gh release view "$TAG" >/dev/null 2>&1 && { echo "✅ Release $TAG exists"; exit 0; }
170
+ gh release create "$TAG" --title "TestGuard $TAG" --notes-file /tmp/release-notes.md --latest
171
+
172
+ publish-npm:
173
+ needs: [detect-version, test, create-release]
174
+ if: needs.detect-version.outputs.npm_missing == 'true'
175
+ runs-on: ubuntu-latest
176
+ permissions:
177
+ contents: read
178
+ id-token: write # mints the OIDC token for npm Trusted Publishing
179
+ steps:
180
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
181
+ # NO registry-url on purpose: with it, setup-node writes an _authToken
182
+ # line that expands to an EMPTY token when NODE_AUTH_TOKEN is unset, and
183
+ # npm then sends that instead of attempting OIDC.
184
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
185
+ with:
186
+ node-version: 24
187
+ - run: npm install -g npm@11
188
+ - name: Assert Trusted Publishing preconditions
189
+ run: |
190
+ NPM_V=$(npm --version)
191
+ echo "node=$(node -p 'process.versions.node') npm=$NPM_V"
192
+ node -e '
193
+ const [maj, min] = process.versions.node.split(".").map(Number);
194
+ if (!(maj > 22 || (maj === 22 && min >= 14))) { console.error("::error::Node below the 22.14.0 floor npm Trusted Publishing requires."); process.exit(1); }
195
+ '
196
+ node -e '
197
+ const v = process.argv[1].split(".").map(Number);
198
+ if (!(v[0] > 11 || (v[0] === 11 && (v[1] > 5 || (v[1] === 5 && v[2] >= 1))))) { console.error("::error::npm below the 11.5.1 floor npm Trusted Publishing requires."); process.exit(1); }
199
+ ' "$NPM_V"
200
+ [ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ] || { echo "::error::no OIDC token endpoint — job lacks id-token: write"; exit 1; }
201
+ - name: Publish (provenance is automatic under Trusted Publishing)
202
+ run: npm publish --loglevel verbose
203
+
204
+ publish-pypi:
205
+ needs: [detect-version, test, create-release]
206
+ if: needs.detect-version.outputs.pypi_missing == 'true'
207
+ runs-on: ubuntu-latest
208
+ environment: pypi
209
+ permissions:
210
+ contents: read
211
+ id-token: write
212
+ steps:
213
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
214
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
215
+ with:
216
+ python-version: '3.12'
217
+ - name: Sync version into pyproject.toml
218
+ run: |
219
+ VERSION="${{ needs.detect-version.outputs.version }}"
220
+ sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
221
+ grep '^version' pyproject.toml
222
+ - run: pip install build
223
+ - run: python -m build
224
+ - name: Assert OIDC token is available
225
+ run: '[ -n "$ACTIONS_ID_TOKEN_REQUEST_URL" ] || { echo "::error::no OIDC token endpoint"; exit 1; }'
226
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
227
+
228
+ homebrew-sha:
229
+ # The formula's sha256 is set from the PUBLISHED tarball — what Homebrew
230
+ # will download, which a provenance-signed publish can make differ byte
231
+ # for byte from `npm pack` — after npm serves it (a publish is `PUT 202`;
232
+ # the file can take minutes to appear, so the script polls, bounded).
233
+ #
234
+ # It cannot commit to main: branch protection requires the three CI legs
235
+ # on every commit and github-actions[bot] is no admin. So the one-line
236
+ # change lands as a bot PR with native auto-merge armed, and this job
237
+ # dispatches ci.yml on the branch itself — a PR opened with GITHUB_TOKEN
238
+ # gets no pull_request run, but workflow_dispatch is the documented
239
+ # exception. That also means the merge triggers nothing here; the hourly
240
+ # sweep observes the merged formula and finds brew_missing=false.
241
+ #
242
+ # Idempotent: an existing branch is reused, an existing open PR is left
243
+ # alone (CI re-dispatched only if its head has no check runs), a closed
244
+ # PR is respected as a human decision, and a formula that already carries
245
+ # the published hash lands nothing.
246
+ needs: [detect-version, test, create-release, publish-npm]
247
+ if: >-
248
+ !cancelled()
249
+ && needs.detect-version.outputs.brew_missing == 'true'
250
+ && needs.test.result != 'failure'
251
+ && needs.create-release.result != 'failure'
252
+ && (needs.publish-npm.result == 'success' || (needs.publish-npm.result == 'skipped' && needs.detect-version.outputs.npm_missing == 'false'))
253
+ runs-on: ubuntu-latest
254
+ permissions:
255
+ contents: write # pushes the chore/homebrew-sha-v* branch
256
+ pull-requests: write # opens the PR and arms auto-merge
257
+ actions: write # dispatches ci.yml on that branch
258
+ steps:
259
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
260
+ with:
261
+ ref: main
262
+ fetch-depth: 0
263
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
264
+ with:
265
+ node-version: 24
266
+ - name: Set sha256 from the published tarball; land it as an auto-merging PR
267
+ env:
268
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
269
+ run: |
270
+ set -euo pipefail
271
+ VERSION="${{ needs.detect-version.outputs.version }}"
272
+ ON_MAIN=$(node -p "require('./package.json').version")
273
+ if [ "$ON_MAIN" != "$VERSION" ]; then
274
+ echo "⏭️ main is already at v${ON_MAIN}, not v${VERSION}; that release sets its own sha256"; exit 0
275
+ fi
276
+ BRANCH="chore/homebrew-sha-v${VERSION}"
277
+ git config user.name "github-actions[bot]"
278
+ git config user.email "github-actions[bot]@users.noreply.github.com"
279
+ if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
280
+ git fetch origin "$BRANCH"
281
+ git checkout -B "$BRANCH" "origin/$BRANCH"
282
+ else
283
+ git checkout -b "$BRANCH"
284
+ fi
285
+
286
+ node .github/scripts/sync-release-version.mjs --sha256 --wait 900
287
+ # Post-condition: the file now on disk matches the tarball, and no
288
+ # other surface drifted. Nothing is pushed unless this passes.
289
+ node .github/scripts/sync-release-version.mjs --check --online
290
+
291
+ if git diff --quiet -- packaging/homebrew/testguard.rb; then
292
+ echo "✅ $BRANCH already carries the published sha256"
293
+ else
294
+ git add packaging/homebrew/testguard.rb
295
+ git commit -m "chore(homebrew): sha256 for v${VERSION}, from the published tarball"
296
+ git push origin "$BRANCH"
297
+ fi
298
+ if [ -z "$(git log --oneline origin/main..HEAD)" ]; then
299
+ echo "✅ main already matches the published tarball; nothing to land"; exit 0
300
+ fi
301
+
302
+ CLOSED=$(gh pr list --head "$BRANCH" --base main --state closed --json url,mergedAt --jq '[.[] | select(.mergedAt == null)][0].url')
303
+ if [ -n "$CLOSED" ]; then
304
+ echo "::warning::$CLOSED was closed without merging; leaving that decision alone. Delete branch $BRANCH to let the sweep retry."; exit 0
305
+ fi
306
+ PR=$(gh pr list --head "$BRANCH" --base main --state open --json url --jq '.[0].url')
307
+ if [ -z "$PR" ]; then
308
+ {
309
+ printf 'Automated: `sha256` of `packaging/homebrew/testguard.rb` set from the published tarball\n\n'
310
+ printf ' https://registry.npmjs.org/testguard-cli/-/testguard-cli-%s.tgz\n\n' "$VERSION"
311
+ printf 'Computed by `.github/scripts/sync-release-version.mjs --sha256` in release.yml after `npm publish`, from the registry — never from a local `npm pack`. `--check --online` confirmed the value before this PR was opened.\n\n'
312
+ printf 'Auto-merge is armed; CI was dispatched on this branch by the release workflow. If the checks never appear: `gh workflow run ci.yml --ref %s`.\n' "$BRANCH"
313
+ } > /tmp/pr-body.md
314
+ if PR=$(gh pr create --base main --head "$BRANCH" --title "chore(homebrew): sha256 for v${VERSION}" --body-file /tmp/pr-body.md 2>/tmp/pr-err); then
315
+ gh pr merge --auto --squash "$PR"
316
+ echo "Opened $PR"
317
+ else
318
+ cat /tmp/pr-err
319
+ if grep -q 'not permitted to create or approve pull requests' /tmp/pr-err; then
320
+ echo "::error::GITHUB_TOKEN may not open a pull request: \"GitHub Actions is not permitted to create or approve pull requests\". The branch is pushed and correct; only the PR is missing. Fix it once, in Settings -> Actions -> General -> Workflow permissions, by ticking \"Allow GitHub Actions to create and approve pull requests\" (equivalently: gh api -X PUT repos/$GITHUB_REPOSITORY/actions/permissions/workflow -F default_workflow_permissions=read -F can_approve_pull_request_reviews=true), then re-run this workflow. Until then, open it by hand: gh pr create --base main --head $BRANCH The formula on main still carries the previous release's hash until then, and detect-version will keep reporting it owed on every hourly sweep."
321
+ else
322
+ echo "::error::gh pr create failed for $BRANCH — see the output above."
323
+ fi
324
+ exit 1
325
+ fi
326
+ else
327
+ echo "✅ PR already open: $PR"
328
+ fi
329
+ HEAD_SHA=$(git rev-parse HEAD)
330
+ RUNS=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --jq '.total_count')
331
+ if [ "${RUNS:-0}" = "0" ]; then
332
+ gh workflow run ci.yml --ref "$BRANCH"
333
+ echo "Dispatched ci.yml on $BRANCH ($HEAD_SHA)"
334
+ else
335
+ echo "✅ $HEAD_SHA already has $RUNS check run(s)"
336
+ fi
@@ -5,10 +5,11 @@ name: Scheduled Release
5
5
  # tree is green, and open a release PR with auto-merge armed. Once merged, the
6
6
  # hourly sweep in release.yml tags and publishes.
7
7
  #
8
- # GITHUB_TOKEN-created PRs do not trigger `pull_request` workflows, so CI on
9
- # the release PR must be started by the maintainer:
10
- # gh workflow run ci.yml --ref release/vX.Y.Z
11
- # After it goes green, auto-merge completes.
8
+ # GITHUB_TOKEN-created PRs do not trigger `pull_request` workflows, so nothing
9
+ # would ever start the required checks and auto-merge would wait forever. This
10
+ # workflow therefore dispatches ci.yml on the release branch itself:
11
+ # `workflow_dispatch` is the one event GITHUB_TOKEN may trigger. After the
12
+ # three Node legs go green, auto-merge completes. No human step remains.
12
13
 
13
14
  on:
14
15
  schedule:
@@ -88,10 +89,23 @@ jobs:
88
89
 
89
90
  - name: Verify the tree is still green
90
91
  if: steps.check.outputs.changed == 'true'
92
+ # --include-dirty is REQUIRED here, not a convenience. The step above
93
+ # bumped the version and synced every surface, and those writes are
94
+ # still uncommitted: the commit happens later, in the PR step. One of
95
+ # those surfaces, packaging/gitlab/testguard.gitlab-ci.yml, is the
96
+ # fault target of TG-GITLAB-PROBE-EXIT-PRESERVED, so the default
97
+ # worktree mode refuses to run at all — "1 defender/target file has
98
+ # uncommitted changes … worktree mode probes HEAD, so those changes
99
+ # would be silently ignored" (exit 2). That refusal is correct; the
100
+ # answer is to probe the tree we are about to release, which is what
101
+ # --include-dirty does (it snapshots the working tree into a scratch
102
+ # commit). --ignore-dirty would probe HEAD and quietly verify the
103
+ # PREVIOUS version instead, which is the optimistic reading this
104
+ # project does not take.
91
105
  run: |
92
106
  npm ci
93
107
  npm test
94
- node cli/testguard.mjs probe . --budget 180000 --quiet --out "$RUNNER_TEMP/self.json"
108
+ node cli/testguard.mjs probe . --budget 180000 --quiet --include-dirty --out "$RUNNER_TEMP/self.json"
95
109
 
96
110
  - name: Open release PR with auto-merge armed
97
111
  if: steps.check.outputs.changed == 'true'
@@ -99,12 +113,17 @@ jobs:
99
113
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
100
114
  run: |
101
115
  BRANCH="release/v${NEW_VERSION}"
102
- # Only release surfaces may change in a release PR.
116
+ # Only release surfaces may change in a release PR. The list is
117
+ # DERIVED from the sync script, never retyped here: when the GitLab
118
+ # template became a surface it was left out of the hand-written list,
119
+ # so the first release to reach this guard would have been rejected
120
+ # for writing a file the release is supposed to write. The three
121
+ # extras are the ones the bump step writes directly.
122
+ ALLOWED=$(node .github/scripts/sync-release-version.mjs --list-surfaces; printf 'package.json\npackage-lock.json\nCHANGELOG.md\n')
123
+ echo "Release surfaces allowed in this diff:"; printf '%s\n' "$ALLOWED" | sed 's/^/ /'
103
124
  CHANGED=$(git diff --name-only HEAD --)
104
125
  for f in $CHANGED; do
105
- case "$f" in package.json|package-lock.json|pyproject.toml|CHANGELOG.md|action.yml|packaging/homebrew/testguard.rb|README.md) ;;
106
- *) echo "::error::unexpected file in release diff: $f"; exit 1;;
107
- esac
126
+ printf '%s\n' "$ALLOWED" | grep -qxF "$f" || { echo "::error::unexpected file in release diff: $f"; exit 1; }
108
127
  done
109
128
  if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
110
129
  echo "::error::branch $BRANCH already exists; refusing to overwrite"; exit 1
@@ -115,7 +134,33 @@ jobs:
115
134
  git add -A
116
135
  git commit -m "release: v${NEW_VERSION} — automated weekly batch"
117
136
  git push origin "$BRANCH"
118
- printf 'Automated weekly release. Version bump + CHANGELOG entry + synced version surfaces. Merging tags and publishes to npm and PyPI via release.yml.\n\nCI does not start automatically on PRs opened by GITHUB_TOKEN. Run:\n\n gh workflow run ci.yml --ref %s\n\nAuto-merge is armed; it completes once the required checks pass.\n' "$BRANCH" > /tmp/pr-body.md
119
- PR=$(gh pr create --base main --head "$BRANCH" --title "release: v${NEW_VERSION} — automated weekly batch" --body-file /tmp/pr-body.md)
120
- gh pr merge --auto --squash "$PR"
121
- echo "Release PR: $PR"
137
+ printf 'Automated weekly release. Version bump + CHANGELOG entry + synced version surfaces. Merging tags and publishes to npm and PyPI via release.yml.\n\nA PR opened by GITHUB_TOKEN gets no `pull_request` run, so this workflow dispatched ci.yml on `%s` itself. Auto-merge is armed; it completes once the three Node legs pass. If the checks never appear: `gh workflow run ci.yml --ref %s`.\n' "$BRANCH" "$BRANCH" > /tmp/pr-body.md
138
+ # A bare `gh pr create` failure here reads as a GraphQL error with no
139
+ # hint of the cause, which is how v0.8.1 lost its release PR after
140
+ # everything else in the release had succeeded.
141
+ if PR=$(gh pr create --base main --head "$BRANCH" --title "release: v${NEW_VERSION} — automated weekly batch" --body-file /tmp/pr-body.md 2>/tmp/pr-err); then
142
+ gh pr merge --auto --squash "$PR"
143
+ echo "Release PR: $PR"
144
+ # Nothing else will start the required checks: a PR opened by
145
+ # GITHUB_TOKEN raises no `pull_request` event, so auto-merge would
146
+ # sit on three checks that never run. `workflow_dispatch` is the
147
+ # one event GITHUB_TOKEN may trigger, and this job already holds
148
+ # the `actions: write` it needs. Guarded so a re-run of an
149
+ # already-tested branch does not queue a second identical run.
150
+ HEAD_SHA=$(git rev-parse HEAD)
151
+ RUNS=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --jq '.total_count')
152
+ if [ "${RUNS:-0}" = "0" ]; then
153
+ gh workflow run ci.yml --ref "$BRANCH"
154
+ echo "Dispatched ci.yml on $BRANCH ($HEAD_SHA)"
155
+ else
156
+ echo "✅ $HEAD_SHA already has $RUNS check run(s)"
157
+ fi
158
+ else
159
+ cat /tmp/pr-err
160
+ if grep -q 'not permitted to create or approve pull requests' /tmp/pr-err; then
161
+ echo "::error::GITHUB_TOKEN may not open a pull request: \"GitHub Actions is not permitted to create or approve pull requests\". The branch is pushed and correct; only the PR is missing. Fix it once, in Settings -> Actions -> General -> Workflow permissions, by ticking \"Allow GitHub Actions to create and approve pull requests\" (equivalently: gh api -X PUT repos/$GITHUB_REPOSITORY/actions/permissions/workflow -F default_workflow_permissions=read -F can_approve_pull_request_reviews=true), then re-run this workflow. Until then, open it by hand: gh pr create --base main --head $BRANCH"
162
+ else
163
+ echo "::error::gh pr create failed for $BRANCH — see the output above."
164
+ fi
165
+ exit 1
166
+ fi