base-cli 0.2.0__tar.gz → 0.4.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 (176) hide show
  1. base_cli-0.4.0/.github/ISSUE_TEMPLATE/support.md +28 -0
  2. base_cli-0.4.0/.github/base-project.yml +9 -0
  3. base_cli-0.4.0/.github/pull_request_template.md +25 -0
  4. base_cli-0.4.0/.github/workflows/compatibility.yml +43 -0
  5. base_cli-0.4.0/.github/workflows/examples.yml +45 -0
  6. base_cli-0.4.0/.github/workflows/issue-branch-policy.yml +508 -0
  7. base_cli-0.4.0/.github/workflows/package.yml +186 -0
  8. base_cli-0.4.0/.github/workflows/project-intake.yml +255 -0
  9. base_cli-0.4.0/.github/workflows/tests.yml +137 -0
  10. base_cli-0.4.0/CHANGELOG.md +159 -0
  11. base_cli-0.4.0/CONTRIBUTING.md +49 -0
  12. base_cli-0.4.0/MANIFEST.in +21 -0
  13. base_cli-0.4.0/PKG-INFO +1088 -0
  14. base_cli-0.4.0/README.md +1042 -0
  15. base_cli-0.4.0/SECURITY.md +73 -0
  16. base_cli-0.4.0/VERSION +1 -0
  17. base_cli-0.4.0/base_manifest.yaml +12 -0
  18. base_cli-0.4.0/compatibility/README.md +34 -0
  19. base_cli-0.4.0/compatibility/consumers/atlas_click/README.md +9 -0
  20. base_cli-0.4.0/compatibility/consumers/atlas_click/pyproject.toml +19 -0
  21. base_cli-0.4.0/compatibility/consumers/atlas_click/src/atlas_click/__init__.py +1 -0
  22. base_cli-0.4.0/compatibility/consumers/atlas_click/src/atlas_click/cli.py +45 -0
  23. base_cli-0.4.0/compatibility/consumers/atlas_click/tests/test_consumer.py +26 -0
  24. base_cli-0.4.0/compatibility/consumers/beacon_typer/README.md +10 -0
  25. base_cli-0.4.0/compatibility/consumers/beacon_typer/pyproject.toml +19 -0
  26. base_cli-0.4.0/compatibility/consumers/beacon_typer/src/beacon_typer/__init__.py +1 -0
  27. base_cli-0.4.0/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py +43 -0
  28. base_cli-0.4.0/compatibility/consumers/beacon_typer/tests/test_consumer.py +25 -0
  29. base_cli-0.4.0/compatibility/consumers/cinder_automation/README.md +12 -0
  30. base_cli-0.4.0/compatibility/consumers/cinder_automation/pyproject.toml +22 -0
  31. base_cli-0.4.0/compatibility/consumers/cinder_automation/src/cinder_automation/__init__.py +1 -0
  32. base_cli-0.4.0/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py +47 -0
  33. base_cli-0.4.0/compatibility/consumers/cinder_automation/tests/test_consumer.py +32 -0
  34. base_cli-0.4.0/compatibility/consumers/manifest.json +26 -0
  35. base_cli-0.4.0/docs/adopter-readiness.md +107 -0
  36. base_cli-0.4.0/docs/api-stability.md +122 -0
  37. base_cli-0.4.0/docs/cache-ownership-and-layout.md +80 -0
  38. base_cli-0.4.0/docs/consumer-profiles.md +192 -0
  39. base_cli-0.4.0/docs/extensions.md +49 -0
  40. base_cli-0.4.0/docs/integrations.md +54 -0
  41. base_cli-0.4.0/docs/json-contracts.md +73 -0
  42. base_cli-0.4.0/docs/local-config.md +23 -0
  43. base_cli-0.4.0/docs/migrations.md +53 -0
  44. base_cli-0.4.0/docs/output-contracts.md +25 -0
  45. base_cli-0.4.0/docs/performance.md +33 -0
  46. base_cli-0.4.0/docs/platform-support.md +56 -0
  47. base_cli-0.4.0/docs/releasing.md +80 -0
  48. base_cli-0.4.0/docs/security-review.md +56 -0
  49. base_cli-0.4.0/docs/security-threat-model.md +123 -0
  50. base_cli-0.4.0/docs/typer-adapter.md +67 -0
  51. base_cli-0.4.0/examples/README.md +34 -0
  52. base_cli-0.4.0/examples/automation_observability_app/README.md +72 -0
  53. base_cli-0.4.0/examples/automation_observability_app/pyproject.toml +28 -0
  54. base_cli-0.4.0/examples/automation_observability_app/src/automation_observability_app/__init__.py +1 -0
  55. base_cli-0.4.0/examples/automation_observability_app/src/automation_observability_app/cli.py +64 -0
  56. base_cli-0.4.0/examples/automation_observability_app/tests/test_cli.py +26 -0
  57. base_cli-0.4.0/examples/minimal_cli/README.md +62 -0
  58. base_cli-0.4.0/examples/minimal_cli/pyproject.toml +19 -0
  59. base_cli-0.4.0/examples/minimal_cli/src/minimal_cli/__init__.py +1 -0
  60. base_cli-0.4.0/examples/minimal_cli/src/minimal_cli/cli.py +31 -0
  61. base_cli-0.4.0/examples/minimal_cli/tests/test_cli.py +27 -0
  62. base_cli-0.4.0/examples/nested_click_app/README.md +62 -0
  63. base_cli-0.4.0/examples/nested_click_app/pyproject.toml +25 -0
  64. base_cli-0.4.0/examples/nested_click_app/src/nested_click_app/__init__.py +1 -0
  65. base_cli-0.4.0/examples/nested_click_app/src/nested_click_app/cli.py +71 -0
  66. base_cli-0.4.0/examples/nested_click_app/src/nested_click_app/plugin.py +9 -0
  67. base_cli-0.4.0/examples/nested_click_app/tests/test_cli.py +30 -0
  68. base_cli-0.4.0/examples/typed_consumer.py +77 -0
  69. base_cli-0.4.0/examples/typer_app/README.md +64 -0
  70. base_cli-0.4.0/examples/typer_app/pyproject.toml +22 -0
  71. base_cli-0.4.0/examples/typer_app/src/typer_app/__init__.py +1 -0
  72. base_cli-0.4.0/examples/typer_app/src/typer_app/cli.py +46 -0
  73. base_cli-0.4.0/examples/typer_app/tests/test_cli.py +26 -0
  74. base_cli-0.4.0/examples/typer_consumer.py +50 -0
  75. base_cli-0.4.0/lib/python/base_cli/__init__.py +250 -0
  76. base_cli-0.4.0/lib/python/base_cli/_cleanup.py +341 -0
  77. base_cli-0.4.0/lib/python/base_cli/_lifecycle.py +173 -0
  78. base_cli-0.4.0/lib/python/base_cli/_private_files.py +194 -0
  79. base_cli-0.4.0/lib/python/base_cli/_runtime.py +676 -0
  80. base_cli-0.4.0/lib/python/base_cli/app.py +3284 -0
  81. base_cli-0.4.0/lib/python/base_cli/attachment.py +69 -0
  82. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/command_protocol.py +128 -30
  83. base_cli-0.4.0/lib/python/base_cli/config.py +274 -0
  84. base_cli-0.4.0/lib/python/base_cli/context.py +193 -0
  85. base_cli-0.4.0/lib/python/base_cli/deprecations.py +53 -0
  86. base_cli-0.4.0/lib/python/base_cli/errors.py +5 -0
  87. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/exit_codes.py +1 -0
  88. base_cli-0.4.0/lib/python/base_cli/extensions.py +323 -0
  89. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/history.py +39 -39
  90. base_cli-0.4.0/lib/python/base_cli/integrations.py +197 -0
  91. base_cli-0.4.0/lib/python/base_cli/json_contracts.py +156 -0
  92. base_cli-0.4.0/lib/python/base_cli/lifecycle_options.py +218 -0
  93. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/logging.py +106 -14
  94. base_cli-0.4.0/lib/python/base_cli/output.py +336 -0
  95. base_cli-0.4.0/lib/python/base_cli/paths.py +117 -0
  96. base_cli-0.4.0/lib/python/base_cli/profile.py +328 -0
  97. base_cli-0.4.0/lib/python/base_cli/redaction.py +616 -0
  98. base_cli-0.4.0/lib/python/base_cli/runtime.py +63 -0
  99. base_cli-0.4.0/lib/python/base_cli/testing.py +99 -0
  100. base_cli-0.4.0/lib/python/base_cli/typer.py +177 -0
  101. base_cli-0.4.0/lib/python/base_cli.egg-info/PKG-INFO +1088 -0
  102. base_cli-0.4.0/lib/python/base_cli.egg-info/SOURCES.txt +157 -0
  103. base_cli-0.4.0/lib/python/base_cli.egg-info/requires.txt +24 -0
  104. {base_cli-0.2.0 → base_cli-0.4.0}/pyproject.toml +42 -0
  105. base_cli-0.4.0/scripts/benchmark_runtime.py +114 -0
  106. base_cli-0.4.0/scripts/validate_consumers.py +73 -0
  107. base_cli-0.4.0/scripts/validate_docs.py +64 -0
  108. base_cli-0.4.0/scripts/validate_examples.py +76 -0
  109. base_cli-0.4.0/scripts/validate_installed_package.py +50 -0
  110. base_cli-0.4.0/scripts/validate_package_artifact.py +171 -0
  111. base_cli-0.4.0/tests/conftest.py +48 -0
  112. base_cli-0.4.0/tests/test_adversarial_regressions.py +390 -0
  113. base_cli-0.4.0/tests/test_api_stability.py +182 -0
  114. base_cli-0.4.0/tests/test_app_lifecycle.py +248 -0
  115. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_quiet.py +1 -1
  116. base_cli-0.4.0/tests/test_app_registration.py +645 -0
  117. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_run.py +108 -13
  118. base_cli-0.4.0/tests/test_app_run_metadata.py +931 -0
  119. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_runtime_errors.py +39 -12
  120. base_cli-0.4.0/tests/test_app_security_boundaries.py +458 -0
  121. base_cli-0.4.0/tests/test_app_startup_transaction.py +392 -0
  122. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_subcommands.py +4 -2
  123. base_cli-0.4.0/tests/test_batteries_included_config.py +173 -0
  124. base_cli-0.4.0/tests/test_cleanup_security.py +482 -0
  125. base_cli-0.4.0/tests/test_click_tree_attachment.py +1728 -0
  126. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_command_protocol.py +22 -0
  127. base_cli-0.4.0/tests/test_explicit_config_validation.py +324 -0
  128. base_cli-0.4.0/tests/test_extensions.py +119 -0
  129. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_generic_core.py +28 -0
  130. base_cli-0.4.0/tests/test_history.py +82 -0
  131. base_cli-0.4.0/tests/test_integrations.py +115 -0
  132. base_cli-0.4.0/tests/test_invocation_parity.py +254 -0
  133. base_cli-0.4.0/tests/test_json_contracts.py +208 -0
  134. base_cli-0.4.0/tests/test_lifecycle_options.py +1356 -0
  135. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_logging.py +28 -0
  136. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_output.py +119 -1
  137. base_cli-0.4.0/tests/test_paths.py +130 -0
  138. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_profile.py +28 -0
  139. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_public_api.py +93 -1
  140. base_cli-0.4.0/tests/test_redaction_security.py +267 -0
  141. base_cli-0.4.0/tests/test_run_bundle_retention.py +166 -0
  142. base_cli-0.4.0/tests/test_security_documentation.py +64 -0
  143. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_testing.py +27 -0
  144. base_cli-0.4.0/tests/test_typed_contracts.py +104 -0
  145. base_cli-0.4.0/tests/test_typer_adapter.py +125 -0
  146. base_cli-0.2.0/PKG-INFO +0 -555
  147. base_cli-0.2.0/README.md +0 -526
  148. base_cli-0.2.0/VERSION +0 -1
  149. base_cli-0.2.0/lib/python/base_cli/__init__.py +0 -108
  150. base_cli-0.2.0/lib/python/base_cli/_private_files.py +0 -38
  151. base_cli-0.2.0/lib/python/base_cli/_runtime.py +0 -130
  152. base_cli-0.2.0/lib/python/base_cli/app.py +0 -470
  153. base_cli-0.2.0/lib/python/base_cli/config.py +0 -28
  154. base_cli-0.2.0/lib/python/base_cli/context.py +0 -102
  155. base_cli-0.2.0/lib/python/base_cli/output.py +0 -198
  156. base_cli-0.2.0/lib/python/base_cli/paths.py +0 -56
  157. base_cli-0.2.0/lib/python/base_cli/profile.py +0 -159
  158. base_cli-0.2.0/lib/python/base_cli/redaction.py +0 -50
  159. base_cli-0.2.0/lib/python/base_cli/testing.py +0 -52
  160. base_cli-0.2.0/lib/python/base_cli.egg-info/PKG-INFO +0 -555
  161. base_cli-0.2.0/lib/python/base_cli.egg-info/SOURCES.txt +0 -45
  162. base_cli-0.2.0/lib/python/base_cli.egg-info/requires.txt +0 -6
  163. {base_cli-0.2.0 → base_cli-0.4.0}/LICENSE +0 -0
  164. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/_dependencies.py +0 -0
  165. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/command_filters.py +0 -0
  166. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/inspection.py +0 -0
  167. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli/py.typed +0 -0
  168. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli.egg-info/dependency_links.txt +0 -0
  169. {base_cli-0.2.0 → base_cli-0.4.0}/lib/python/base_cli.egg-info/top_level.txt +0 -0
  170. {base_cli-0.2.0 → base_cli-0.4.0}/setup.cfg +0 -0
  171. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_dry_run.py +0 -0
  172. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_log_retention.py +0 -0
  173. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_app_runtime_boundary.py +0 -0
  174. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_command_filters.py +0 -0
  175. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_context_workspace.py +0 -0
  176. {base_cli-0.2.0 → base_cli-0.4.0}/tests/test_inspection.py +0 -0
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: Adoption support
3
+ about: Request migration or downstream compatibility help
4
+ title: "[Support] "
5
+ labels: ""
6
+ assignees: ""
7
+ ---
8
+
9
+ ## What are you adopting?
10
+
11
+ Describe the CLI, its Click/Typer version, and the intended production use case.
12
+
13
+ ## Reproduction
14
+
15
+ Provide a minimal command and the exact exit code. Remove credentials, tokens,
16
+ customer data, and private paths.
17
+
18
+ ## Environment
19
+
20
+ - `base-cli` version:
21
+ - Python version:
22
+ - Operating system:
23
+ - Click/Typer and optional integration versions:
24
+
25
+ ## Evidence
26
+
27
+ Attach or link a redacted log/run bundle and say whether the failure occurs
28
+ against an installed wheel. Security-sensitive reports belong in `SECURITY.md`.
@@ -0,0 +1,9 @@
1
+ project:
2
+ areas: []
3
+ initiatives: []
4
+ issue_defaults:
5
+ status: Backlog
6
+ priority: P2
7
+ area: Product
8
+ initiative: Adoption Polish
9
+ size: S
@@ -0,0 +1,25 @@
1
+ ## Summary
2
+
3
+ <!-- What changed and why. Focus on decisions and user impact, not just the diff. -->
4
+
5
+ ## Issue
6
+
7
+ Closes #
8
+
9
+ ## Validation
10
+
11
+ <!-- Commands run and relevant output. Include narrow checks and any broader suite used. -->
12
+
13
+ ## Notes
14
+
15
+ <!-- Optional: tradeoffs, follow-up work, or reviewer context. -->
16
+
17
+ ## Checklist
18
+
19
+ - [ ] Branch name follows `<category>/<issue>-<YYYYMMDD>-<slug>`, and its category prefix matches the issue's single standard category label.
20
+ - [ ] Pull request is scoped to one issue, unless a documented multi-issue exception applies.
21
+ - [ ] Pull request body explains what changed and how it was validated.
22
+ - [ ] Relevant project checks pass.
23
+ - [ ] Documentation is updated when behavior or user-facing commands change.
24
+ - [ ] CHANGELOG is updated for notable user-visible or release-worthy changes.
25
+ - [ ] Pull request includes `Fixes #<issue>` or `Closes #<issue>` when merge should close the issue.
@@ -0,0 +1,43 @@
1
+ name: Reference consumers
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ downstream:
16
+ name: Install and test independent consumers
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 15
19
+ steps:
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
+ - name: Set up Python
22
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
23
+ with:
24
+ python-version: "3.13"
25
+ - name: Install framework build and test dependencies
26
+ run: python -m pip install ".[dev,typer,rich,telemetry]"
27
+ - name: Build and install the framework wheel
28
+ run: |
29
+ python -m build --wheel
30
+ python -m pip uninstall --yes base-cli
31
+ python -m pip install dist/*.whl
32
+ - name: Validate consumer manifest
33
+ run: python scripts/validate_consumers.py
34
+ - name: Install consumers independently
35
+ run: |
36
+ python -m pip install compatibility/consumers/atlas_click
37
+ python -m pip install compatibility/consumers/beacon_typer
38
+ python -m pip install "compatibility/consumers/cinder_automation[observability]"
39
+ - name: Run downstream compatibility suites
40
+ run: |
41
+ for tests in compatibility/consumers/*/tests; do
42
+ python -m pytest "$tests"
43
+ done
@@ -0,0 +1,45 @@
1
+ name: Reference examples
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ install-and-test:
16
+ name: Build wheel, install examples, and test
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 15
19
+ steps:
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
+ - name: Set up Python
22
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
23
+ with:
24
+ python-version: "3.13"
25
+ - name: Install framework build and test dependencies
26
+ run: python -m pip install ".[dev,typer,rich,telemetry]"
27
+ - name: Build and install the framework wheel
28
+ run: |
29
+ python -m build --wheel
30
+ python -m pip uninstall --yes base-cli
31
+ python -m pip install dist/*.whl
32
+ - name: Validate reference-app structure
33
+ run: python scripts/validate_examples.py
34
+ - name: Install each example independently
35
+ run: |
36
+ for example in examples/*; do
37
+ if [[ -f "$example/pyproject.toml" ]]; then
38
+ python -m pip install "$example"
39
+ fi
40
+ done
41
+ - name: Run reference-app tests
42
+ run: |
43
+ for tests in examples/*/tests; do
44
+ python -m pytest "$tests"
45
+ done
@@ -0,0 +1,508 @@
1
+ name: Issue Branch Policy
2
+
3
+ on:
4
+ pull_request_target:
5
+ types:
6
+ - opened
7
+ - reopened
8
+ - synchronize
9
+ - closed
10
+ issues:
11
+ types:
12
+ - labeled
13
+ - unlabeled
14
+ workflow_dispatch:
15
+ inputs:
16
+ pull_request_number:
17
+ description: "Pull request number to validate"
18
+ required: true
19
+ type: string
20
+ head_sha:
21
+ description: "Head commit SHA used for same-commit concurrency"
22
+ required: true
23
+ type: string
24
+
25
+ permissions:
26
+ actions: write
27
+ contents: read
28
+ issues: read
29
+ pull-requests: read
30
+ statuses: write
31
+
32
+ concurrency:
33
+ group: ${{ github.workflow }}-${{ github.event.issue.number && format('issue-{0}-{1}', github.event.issue.number, github.event.label.name) || format('sha-{0}', github.event.pull_request.head.sha || inputs.head_sha || github.run_id) }}
34
+ cancel-in-progress: true
35
+
36
+ jobs:
37
+ policy:
38
+ name: Publish issue branch policy
39
+ runs-on: ubuntu-latest
40
+ timeout-minutes: 5
41
+ env:
42
+ GH_TOKEN: ${{ github.token }}
43
+ POLICY_CONTEXT: base/issue-branch-policy
44
+ PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }}
45
+ ISSUE_EVENT_NUMBER: ${{ github.event.issue.number }}
46
+ ISSUE_EVENT_LABEL: ${{ github.event.label.name }}
47
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
48
+ EVENT_NAME: ${{ github.event_name }}
49
+ EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
50
+ DISPATCH_HEAD_SHA: ${{ inputs.head_sha }}
51
+ PREVIOUS_HEAD_SHA: ${{ github.event.before }}
52
+ RUN_SHA: ${{ github.sha }}
53
+
54
+ steps:
55
+ - name: Validate issue-backed branch
56
+ run: |
57
+ publish_status() {
58
+ local head_sha="$1"
59
+ local state="$2"
60
+ local description="$3"
61
+
62
+ gh api "repos/$GITHUB_REPOSITORY/statuses/$head_sha" \
63
+ --method POST \
64
+ -f state="$state" \
65
+ -f context="$POLICY_CONTEXT" \
66
+ -f description="$description" \
67
+ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
68
+ >/dev/null
69
+ }
70
+
71
+ fail_policy() {
72
+ local head_sha="$1"
73
+ local description="$2"
74
+
75
+ publish_status "$head_sha" failure "$description" || true
76
+ printf '%s\n' "$description" >&2
77
+ return 1
78
+ }
79
+
80
+ publish_readiness() {
81
+ if [[ ! "$RUN_SHA" =~ ^[0-9a-f]{40}$ ]]; then
82
+ printf 'Workflow run did not provide a valid trusted SHA.\n' >&2
83
+ return 1
84
+ fi
85
+ publish_status "$RUN_SHA" success "Issue branch policy workflow is ready"
86
+ }
87
+
88
+ category_is_standard() {
89
+ case "$1" in
90
+ bug | enhancement | documentation | ci | security) return 0 ;;
91
+ *) return 1 ;;
92
+ esac
93
+ }
94
+
95
+ calendar_date_is_valid() {
96
+ local value="$1"
97
+ local year month day days_in_month
98
+
99
+ [[ "$value" =~ ^[0-9]{8}$ ]] || return 1
100
+ year=$((10#${value:0:4}))
101
+ month=$((10#${value:4:2}))
102
+ day=$((10#${value:6:2}))
103
+ ((year >= 1 && month >= 1 && month <= 12 && day >= 1)) || return 1
104
+
105
+ case "$month" in
106
+ 1 | 3 | 5 | 7 | 8 | 10 | 12) days_in_month=31 ;;
107
+ 4 | 6 | 9 | 11) days_in_month=30 ;;
108
+ 2)
109
+ days_in_month=28
110
+ if ((year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))); then
111
+ days_in_month=29
112
+ fi
113
+ ;;
114
+ esac
115
+
116
+ ((day <= days_in_month))
117
+ }
118
+
119
+ branch_pattern='^(bug|enhancement|documentation|ci|security)/([1-9][0-9]*)-([0-9]{8})-[a-z0-9]+(-[a-z0-9]+)*$'
120
+
121
+ validate_pull_request() {
122
+ local pull_request_number="$1"
123
+ local expected_head_sha="$2"
124
+ local pull_request_json branch_name head_sha
125
+ local category issue branch_date issue_json category_lines
126
+ local issue_categories=()
127
+ local issue_category
128
+
129
+ if [[ ! "$pull_request_number" =~ ^[1-9][0-9]*$ ]]; then
130
+ printf 'Pull request number must be a positive integer.\n' >&2
131
+ return 1
132
+ fi
133
+ pull_request_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pull_request_number")" || {
134
+ printf 'Unable to read pull request #%s.\n' "$pull_request_number" >&2
135
+ return 1
136
+ }
137
+ branch_name="$(jq -r '.head.ref // ""' <<< "$pull_request_json")" || {
138
+ printf 'Pull request #%s did not provide a readable branch name.\n' "$pull_request_number" >&2
139
+ return 1
140
+ }
141
+ head_sha="$(jq -r '.head.sha // ""' <<< "$pull_request_json")" || {
142
+ printf 'Pull request #%s did not provide a readable head SHA.\n' "$pull_request_number" >&2
143
+ return 1
144
+ }
145
+ if [[ ! "$head_sha" =~ ^[0-9a-f]{40}$ ]]; then
146
+ printf 'Pull request #%s did not provide a valid head SHA.\n' "$pull_request_number" >&2
147
+ return 1
148
+ fi
149
+ if [[ "$head_sha" != "$expected_head_sha" ]]; then
150
+ printf 'Pull request #%s no longer shares commit %s; skipping it.\n' \
151
+ "$pull_request_number" "$expected_head_sha"
152
+ return 2
153
+ fi
154
+
155
+ if [[ ! "$branch_name" =~ $branch_pattern ]]; then
156
+ printf 'Pull request #%s: branch name is not canonical.\n' \
157
+ "$pull_request_number" >&2
158
+ return 1
159
+ fi
160
+
161
+ category="${BASH_REMATCH[1]}"
162
+ issue="${BASH_REMATCH[2]}"
163
+ branch_date="${BASH_REMATCH[3]}"
164
+ if ! calendar_date_is_valid "$branch_date"; then
165
+ printf 'Pull request #%s: branch date is not a valid YYYYMMDD date.\n' \
166
+ "$pull_request_number" >&2
167
+ return 1
168
+ fi
169
+
170
+ issue_json="$(gh api "repos/$GITHUB_REPOSITORY/issues/$issue" 2>/dev/null)" || {
171
+ printf 'Pull request #%s: referenced issue #%s does not exist.\n' \
172
+ "$pull_request_number" "$issue" >&2
173
+ return 1
174
+ }
175
+
176
+ if jq -e 'has("pull_request")' >/dev/null <<< "$issue_json"; then
177
+ printf 'Pull request #%s: #%s is a pull request, not an issue.\n' \
178
+ "$pull_request_number" "$issue" >&2
179
+ return 1
180
+ fi
181
+
182
+ category_lines="$(
183
+ jq -r '
184
+ [.labels[].name
185
+ | select(
186
+ . == "bug" or
187
+ . == "enhancement" or
188
+ . == "documentation" or
189
+ . == "ci" or
190
+ . == "security"
191
+ )]
192
+ | unique
193
+ | .[]
194
+ ' <<< "$issue_json"
195
+ )" || {
196
+ printf 'Pull request #%s: unable to read category labels from issue #%s.\n' \
197
+ "$pull_request_number" "$issue" >&2
198
+ return 1
199
+ }
200
+ while IFS= read -r issue_category; do
201
+ [[ -z "$issue_category" ]] || issue_categories+=("$issue_category")
202
+ done <<< "$category_lines"
203
+
204
+ if ((${#issue_categories[@]} != 1)); then
205
+ printf 'Pull request #%s: issue #%s needs exactly one category label.\n' \
206
+ "$pull_request_number" "$issue" >&2
207
+ return 1
208
+ fi
209
+ if [[ "${issue_categories[0]}" != "$category" ]]; then
210
+ printf 'Pull request #%s: branch category does not match issue #%s.\n' \
211
+ "$pull_request_number" "$issue" >&2
212
+ return 1
213
+ fi
214
+
215
+ printf 'Validated %s against issue #%s (%s).\n' "$branch_name" "$issue" "$category"
216
+ }
217
+
218
+ if [[ -n "$ISSUE_EVENT_NUMBER" ]]; then
219
+ if [[ ! "$ISSUE_EVENT_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
220
+ printf 'Issue event number must be a positive integer.\n' >&2
221
+ exit 1
222
+ fi
223
+ if ! category_is_standard "$ISSUE_EVENT_LABEL"; then
224
+ printf 'Label %s does not affect the branch category policy.\n' "$ISSUE_EVENT_LABEL"
225
+ exit 0
226
+ fi
227
+ if [[ -z "$DEFAULT_BRANCH" ]]; then
228
+ printf 'Issue event did not provide the repository default branch.\n' >&2
229
+ exit 1
230
+ fi
231
+
232
+ open_pull_requests="$(
233
+ gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \
234
+ --paginate \
235
+ --jq '.[] | [.number, .head.ref, .head.sha] | @tsv'
236
+ )" || {
237
+ printf 'Unable to list open pull requests for issue #%s.\n' "$ISSUE_EVENT_NUMBER" >&2
238
+ exit 1
239
+ }
240
+
241
+ matched=0
242
+ overall_status=0
243
+ while IFS=$'\t' read -r candidate_number candidate_branch candidate_head_sha; do
244
+ [[ -n "$candidate_number" ]] || continue
245
+ if [[ ! "$candidate_number" =~ ^[1-9][0-9]*$ ||
246
+ -z "$candidate_branch" || ! "$candidate_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
247
+ printf 'GitHub returned an invalid open pull request record.\n' >&2
248
+ overall_status=1
249
+ continue
250
+ fi
251
+ if [[ "$candidate_branch" =~ $branch_pattern ]] &&
252
+ [[ "${BASH_REMATCH[2]}" == "$ISSUE_EVENT_NUMBER" ]]; then
253
+ matched=$((matched + 1))
254
+ if ! publish_status "$candidate_head_sha" pending \
255
+ "Issue category changed; revalidating branch policy"; then
256
+ printf 'Unable to publish pending status for pull request #%s.\n' \
257
+ "$candidate_number" >&2
258
+ overall_status=1
259
+ fi
260
+ if gh api \
261
+ "repos/$GITHUB_REPOSITORY/actions/workflows/issue-branch-policy.yml/dispatches" \
262
+ --method POST \
263
+ -f ref="$DEFAULT_BRANCH" \
264
+ -f "inputs[pull_request_number]=$candidate_number" \
265
+ -f "inputs[head_sha]=$candidate_head_sha"; then
266
+ printf 'Queued branch-policy revalidation for pull request #%s.\n' \
267
+ "$candidate_number"
268
+ else
269
+ publish_status "$candidate_head_sha" failure \
270
+ "Unable to queue issue-label branch policy validation" || true
271
+ printf 'Unable to queue branch-policy revalidation for pull request #%s.\n' \
272
+ "$candidate_number" >&2
273
+ overall_status=1
274
+ fi
275
+ fi
276
+ done <<< "$open_pull_requests"
277
+
278
+ if ((matched == 0)); then
279
+ printf 'No open pull requests reference issue #%s.\n' "$ISSUE_EVENT_NUMBER"
280
+ fi
281
+ exit "$overall_status"
282
+ fi
283
+
284
+ case "$EVENT_NAME" in
285
+ pull_request_target)
286
+ if [[ ! "$EVENT_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
287
+ printf 'Pull request event did not provide a valid head SHA.\n' >&2
288
+ exit 1
289
+ fi
290
+ target_head_sha="$EVENT_HEAD_SHA"
291
+ ;;
292
+ workflow_dispatch)
293
+ if [[ ! "$DISPATCH_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
294
+ printf 'Dispatched head SHA must be a lowercase 40-character commit SHA.\n' >&2
295
+ exit 1
296
+ fi
297
+ target_head_sha="$DISPATCH_HEAD_SHA"
298
+ ;;
299
+ *)
300
+ printf 'Unsupported branch policy event: %s.\n' "$EVENT_NAME" >&2
301
+ exit 1
302
+ ;;
303
+ esac
304
+
305
+ if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
306
+ fail_policy "$target_head_sha" "Pull request number must be a positive integer" || true
307
+ exit 1
308
+ fi
309
+ requested_pull_request_json="$(
310
+ gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER"
311
+ )" || {
312
+ fail_policy "$target_head_sha" \
313
+ "Unable to read pull request #$PULL_REQUEST_NUMBER" || true
314
+ exit 1
315
+ }
316
+ requested_pull_request_number="$(
317
+ jq -r '.number // ""' <<< "$requested_pull_request_json"
318
+ )" || {
319
+ fail_policy "$target_head_sha" \
320
+ "Pull request response did not provide a readable number" || true
321
+ exit 1
322
+ }
323
+ requested_head_sha="$(
324
+ jq -r '.head.sha // ""' <<< "$requested_pull_request_json"
325
+ )" || {
326
+ fail_policy "$target_head_sha" \
327
+ "Pull request response did not provide a readable head SHA" || true
328
+ exit 1
329
+ }
330
+ requested_state="$(
331
+ jq -r '.state // ""' <<< "$requested_pull_request_json"
332
+ )" || {
333
+ fail_policy "$target_head_sha" \
334
+ "Pull request response did not provide a readable state" || true
335
+ exit 1
336
+ }
337
+ if [[ "$requested_pull_request_number" != "$PULL_REQUEST_NUMBER" ]]; then
338
+ fail_policy "$target_head_sha" \
339
+ "Pull request API response did not match the requested pull request" || true
340
+ exit 1
341
+ fi
342
+ if [[ ! "$requested_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
343
+ fail_policy "$target_head_sha" \
344
+ "Pull request response did not provide a valid head SHA" || true
345
+ exit 1
346
+ fi
347
+ if [[ "$requested_state" != "open" && "$requested_state" != "closed" ]]; then
348
+ fail_policy "$target_head_sha" \
349
+ "Pull request response did not provide a valid state" || true
350
+ exit 1
351
+ fi
352
+
353
+ target_matches_live=0
354
+ [[ "$target_head_sha" != "$requested_head_sha" ]] || target_matches_live=1
355
+ repair_previous=0
356
+ if [[ "$EVENT_NAME" == "pull_request_target" && -n "$PREVIOUS_HEAD_SHA" ]]; then
357
+ if [[ ! "$PREVIOUS_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
358
+ fail_policy "$target_head_sha" \
359
+ "Synchronize event did not provide a valid previous head SHA" || true
360
+ exit 1
361
+ fi
362
+ [[ "$PREVIOUS_HEAD_SHA" == "$target_head_sha" ]] || repair_previous=1
363
+ fi
364
+
365
+ if ((target_matches_live == 0 && repair_previous == 0)); then
366
+ printf 'Skipping stale %s for pull request #%s: expected %s, now at %s.\n' \
367
+ "$EVENT_NAME" "$PULL_REQUEST_NUMBER" "$target_head_sha" "$requested_head_sha"
368
+ exit 0
369
+ fi
370
+
371
+ open_pull_request_heads="$(
372
+ gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \
373
+ --paginate \
374
+ --jq '.[] | [.number, .head.sha] | @tsv'
375
+ )" || {
376
+ if ((repair_previous != 0)); then
377
+ publish_status "$PREVIOUS_HEAD_SHA" failure \
378
+ "Unable to inspect PRs remaining on the previous commit" || true
379
+ fi
380
+ fail_policy "$target_head_sha" "Unable to inspect PRs sharing this commit" || true
381
+ exit 1
382
+ }
383
+
384
+ if ((repair_previous != 0)); then
385
+ previous_pull_request_number=""
386
+ previous_heads_valid=1
387
+ while IFS=$'\t' read -r candidate_number candidate_head_sha; do
388
+ [[ -n "$candidate_number" ]] || continue
389
+ if [[ ! "$candidate_number" =~ ^[1-9][0-9]*$ ||
390
+ ! "$candidate_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
391
+ printf 'GitHub returned an invalid open pull request head record.\n' >&2
392
+ previous_heads_valid=0
393
+ continue
394
+ fi
395
+ if [[ -z "$previous_pull_request_number" &&
396
+ "$candidate_head_sha" == "$PREVIOUS_HEAD_SHA" ]]; then
397
+ previous_pull_request_number="$candidate_number"
398
+ fi
399
+ done <<< "$open_pull_request_heads"
400
+
401
+ if ((previous_heads_valid == 0)); then
402
+ publish_status "$PREVIOUS_HEAD_SHA" failure \
403
+ "Unable to inspect PRs remaining on the previous commit" || true
404
+ fail_policy "$target_head_sha" \
405
+ "Unable to inspect PRs remaining on the previous commit" || true
406
+ exit 1
407
+ fi
408
+ if [[ -n "$previous_pull_request_number" ]]; then
409
+ if [[ -z "$DEFAULT_BRANCH" ]]; then
410
+ publish_status "$PREVIOUS_HEAD_SHA" failure \
411
+ "Synchronize event did not provide the default branch" || true
412
+ if ((target_matches_live != 0)); then
413
+ fail_policy "$target_head_sha" \
414
+ "Synchronize event did not provide the repository default branch" || true
415
+ fi
416
+ exit 1
417
+ fi
418
+ if ! publish_status "$PREVIOUS_HEAD_SHA" pending \
419
+ "Revalidating PRs remaining on the previous commit"; then
420
+ printf 'Unable to publish pending status for previous commit %s; dispatching anyway.\n' \
421
+ "$PREVIOUS_HEAD_SHA" >&2
422
+ fi
423
+ if ! gh api \
424
+ "repos/$GITHUB_REPOSITORY/actions/workflows/issue-branch-policy.yml/dispatches" \
425
+ --method POST \
426
+ -f ref="$DEFAULT_BRANCH" \
427
+ -f "inputs[pull_request_number]=$previous_pull_request_number" \
428
+ -f "inputs[head_sha]=$PREVIOUS_HEAD_SHA"; then
429
+ publish_status "$PREVIOUS_HEAD_SHA" failure \
430
+ "Unable to queue previous-commit branch policy validation" || true
431
+ if ((target_matches_live != 0)); then
432
+ fail_policy "$target_head_sha" \
433
+ "Unable to queue previous-commit branch policy validation" || true
434
+ else
435
+ printf 'Unable to queue previous-commit branch policy validation.\n' >&2
436
+ fi
437
+ exit 1
438
+ fi
439
+ printf 'Queued previous-commit validation for pull request #%s at %s.\n' \
440
+ "$previous_pull_request_number" "$PREVIOUS_HEAD_SHA"
441
+ else
442
+ printf 'No open pull requests remain on previous commit %s.\n' \
443
+ "$PREVIOUS_HEAD_SHA"
444
+ fi
445
+ fi
446
+
447
+ if ((target_matches_live == 0)); then
448
+ printf 'Skipping stale pull request event for pull request #%s after repairing %s; now at %s.\n' \
449
+ "$PULL_REQUEST_NUMBER" "$PREVIOUS_HEAD_SHA" "$requested_head_sha"
450
+ exit 0
451
+ fi
452
+
453
+ if ! publish_status "$target_head_sha" pending \
454
+ "Validating every open PR sharing this commit"; then
455
+ printf 'Unable to publish the pending branch policy status for pull request #%s.\n' \
456
+ "$PULL_REQUEST_NUMBER" >&2
457
+ fi
458
+
459
+ overall_status=0
460
+ requested_validated=0
461
+ validated_count=0
462
+ while IFS=$'\t' read -r candidate_number candidate_head_sha; do
463
+ [[ -n "$candidate_number" ]] || continue
464
+ if [[ ! "$candidate_number" =~ ^[1-9][0-9]*$ ||
465
+ ! "$candidate_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
466
+ printf 'GitHub returned an invalid open pull request head record.\n' >&2
467
+ overall_status=1
468
+ continue
469
+ fi
470
+ [[ "$candidate_head_sha" == "$target_head_sha" ]] || continue
471
+
472
+ if validate_pull_request "$candidate_number" "$target_head_sha"; then
473
+ validated_count=$((validated_count + 1))
474
+ [[ "$candidate_number" != "$PULL_REQUEST_NUMBER" ]] || requested_validated=1
475
+ else
476
+ validation_status=$?
477
+ if ((validation_status != 2)); then
478
+ validated_count=$((validated_count + 1))
479
+ overall_status=1
480
+ [[ "$candidate_number" != "$PULL_REQUEST_NUMBER" ]] || requested_validated=1
481
+ fi
482
+ fi
483
+ done <<< "$open_pull_request_heads"
484
+
485
+ if [[ "$requested_state" == "open" && "$requested_validated" == "0" ]] ||
486
+ ((validated_count == 0)); then
487
+ if validate_pull_request "$PULL_REQUEST_NUMBER" "$target_head_sha"; then
488
+ validated_count=$((validated_count + 1))
489
+ else
490
+ overall_status=1
491
+ fi
492
+ fi
493
+
494
+ if ((overall_status != 0)); then
495
+ fail_policy "$target_head_sha" \
496
+ "One or more PRs sharing this commit violate branch policy" || true
497
+ exit 1
498
+ fi
499
+
500
+ publish_status "$target_head_sha" success \
501
+ "All PRs sharing this commit match branch policy" || {
502
+ printf 'Unable to publish the successful branch policy status for pull request #%s.\n' \
503
+ "$PULL_REQUEST_NUMBER" >&2
504
+ exit 1
505
+ }
506
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
507
+ publish_readiness
508
+ fi