From 5ccef020583043a897e84c22494d18f953f232d4 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 27 Aug 2026 10:04:46 +1200 Subject: [PATCH 1/5] feat: add charm-tech-baseline, the repo-setup audit tool The audit has been living as 4,500 lines of scripts inside a skill in canonical/charm-tech, where it has no lockfile, no CI and no tests that anything runs. This is the half that is code: 29 checks, 8 mechanical fixes, tier detection, and the templates and question batteries they read. The skill keeps the half that is prose - when a check applies, what a finding means, and which decisions are already settled - and drives this through uvx. Ported rather than rewritten, so the report is byte-identical to what the scripts produced. Three changes were needed to make it a package: * Checks are imported and called by the runner instead of being shelled out to and having their stdout reparsed. emit_check hands the result straight over when a collector is active, and still prints when a check is invoked on its own, which is how the tests drive them. * The runner sets sys.argv for each check rather than letting it read the runner's own command line. That was a real defect: a check only ever saw --tier because the runner happened to have been given the same flag, so a detected tier never reached one. * PyYAML becomes a dependency instead of three `# /// script` blocks. The long message strings are wrapped to the shared 99-column ruff config by implicit concatenation, so no message text changed. Confirmed by diffing a full report against canonical/pebble before and after: the checks and notes are identical. --- .github/workflows/ci.yaml | 2 +- README.md | 1 + charm-tech-baseline/README.md | 61 +++ charm-tech-baseline/pyproject.toml | 36 ++ .../charm_tech_baseline/__init__.py | 9 + .../assets/AGENTS.md.template | 40 ++ .../assets/CODE_OF_CONDUCT.md | 5 + .../assets/CONTRIBUTING.md.template | 66 +++ .../assets/SECURITY.md.template | 40 ++ .../check-conventional-pr-title.py.template | 87 ++++ .../assets/dependabot.yaml.template | 100 ++++ .../question-batteries/api_demo_server.yaml | 127 +++++ .../question-batteries/charm-ubuntu.yaml | 143 ++++++ .../assets/question-batteries/charmlibs.yaml | 432 +++++++++++++++++ .../assets/question-batteries/concierge.yaml | 195 ++++++++ .../assets/question-batteries/pebble.yaml | 183 +++++++ .../question-batteries/pytest-jubilant.yaml | 162 +++++++ .../assets/sbom-secscan.yaml.template | 86 ++++ .../sbomber-manifest-sdist.yaml.template | 24 + .../sbomber-manifest-wheel.yaml.template | 23 + .../trusted-publishing-product.yaml.template | 71 +++ .../assets/trusted-publishing.yaml.template | 88 ++++ .../assets/validate-pr-title.yaml.template | 21 + .../charm_tech_baseline/checks/__init__.py | 0 .../charm_tech_baseline/checks/agents_md.py | 77 +++ .../checks/agents_md_battery.py | 333 +++++++++++++ .../checks/agents_md_content.py | 448 ++++++++++++++++++ .../checks/attest_build_provenance.py | 255 ++++++++++ .../checks/attest_sbom_deprecated.py | 79 +++ .../checks/code_of_conduct.py | 84 ++++ .../checks/contributing.py | 105 ++++ .../checks/conventional_commits.py | 94 ++++ .../charm_tech_baseline/checks/dependabot.py | 263 ++++++++++ .../checks/dependency_review.py | 78 +++ .../checks/gha_sha_pinning.py | 106 +++++ .../checks/immutable_releases.py | 109 +++++ .../checks/openssf_scorecard.py | 113 +++++ .../checks/pre_commit_config.py | 169 +++++++ .../checks/repo_settings.py | 322 +++++++++++++ .../checks/sbom_workflow.py | 203 ++++++++ .../checks/sec0030_coverage.py | 108 +++++ .../checks/sec0045_events.py | 142 ++++++ .../checks/secscan_workflow.py | 226 +++++++++ .../charm_tech_baseline/checks/security_md.py | 93 ++++ .../checks/threat_model_drive.py | 53 +++ .../checks/tiobe_config.py | 179 +++++++ .../checks/tqi_security_target.py | 52 ++ .../checks/trusted_publishing.py | 145 ++++++ .../checks/uv_exclude_newer.py | 315 ++++++++++++ .../checks/vulnerability_response_plan.py | 53 +++ .../checks/workflow_secrets.py | 174 +++++++ .../checks/yaml_extension.py | 79 +++ .../checks/zizmor_config.py | 86 ++++ .../charm_tech_baseline/cli.py | 200 ++++++++ .../charm_tech_baseline/common.py | 155 ++++++ .../charm_tech_baseline/fixes/__init__.py | 0 .../fixes/add_agents_md.py | 44 ++ .../fixes/add_code_of_conduct.py | 41 ++ .../fixes/add_contributing.py | 62 +++ .../fixes/add_dependabot.py | 45 ++ .../fixes/add_security_md.py | 48 ++ .../fixes/add_validate_pr_title.py | 101 ++++ .../fixes/apply_repo_settings.py | 144 ++++++ .../fixes/rename_yml_to_yaml.py | 66 +++ .../charm_tech_baseline/tier.py | 108 +++++ .../tests/checks/test_agents_md_battery.py | 185 ++++++++ .../tests/checks/test_agents_md_content.py | 200 ++++++++ .../tests/checks/test_dependabot.py | 47 ++ charm-tech-baseline/tests/conftest.py | 45 ++ .../tests/test_check_runner.py | 26 + charm-tech-baseline/tests/test_detect_tier.py | 45 ++ charm-tech-baseline/uv.lock | 224 +++++++++ 72 files changed, 8330 insertions(+), 1 deletion(-) create mode 100644 charm-tech-baseline/README.md create mode 100644 charm-tech-baseline/pyproject.toml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/__init__.py create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/AGENTS.md.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CODE_OF_CONDUCT.md create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CONTRIBUTING.md.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/SECURITY.md.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/check-conventional-pr-title.py.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/dependabot.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/api_demo_server.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charm-ubuntu.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charmlibs.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/concierge.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pebble.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pytest-jubilant.yaml create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbom-secscan.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-sdist.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-wheel.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/validate-pr-title.yaml.template create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/__init__.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_battery.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_content.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_sbom_deprecated.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/code_of_conduct.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/contributing.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/conventional_commits.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependabot.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependency_review.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/gha_sha_pinning.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/immutable_releases.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/openssf_scorecard.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/pre_commit_config.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/repo_settings.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sbom_workflow.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0030_coverage.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0045_events.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/secscan_workflow.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/security_md.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/threat_model_drive.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tiobe_config.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tqi_security_target.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/trusted_publishing.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/uv_exclude_newer.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/vulnerability_response_plan.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/workflow_secrets.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/yaml_extension.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/zizmor_config.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/cli.py create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/common.py create mode 100644 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/__init__.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_agents_md.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_code_of_conduct.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_contributing.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_dependabot.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_security_md.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_validate_pr_title.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/apply_repo_settings.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/rename_yml_to_yaml.py create mode 100755 charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/tier.py create mode 100644 charm-tech-baseline/tests/checks/test_agents_md_battery.py create mode 100644 charm-tech-baseline/tests/checks/test_agents_md_content.py create mode 100644 charm-tech-baseline/tests/checks/test_dependabot.py create mode 100644 charm-tech-baseline/tests/conftest.py create mode 100644 charm-tech-baseline/tests/test_check_runner.py create mode 100644 charm-tech-baseline/tests/test_detect_tier.py create mode 100644 charm-tech-baseline/uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01451d9..47e0441 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: # One entry per tool. Add a directory here when you add a package. - package: [ai-failure-notifier] + package: [ai-failure-notifier, charm-tech-baseline] python-version: ['3.10', '3.12', '3.14'] defaults: run: diff --git a/README.md b/README.md index 91ba797..f89c1fa 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | +| [`charm-tech-baseline`](charm-tech-baseline) | Audits a repository against the Charm Tech baseline, and applies the mechanical fixes. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/charm-tech-baseline/README.md b/charm-tech-baseline/README.md new file mode 100644 index 0000000..4ddb995 --- /dev/null +++ b/charm-tech-baseline/README.md @@ -0,0 +1,61 @@ +# charm-tech-baseline + +Audits a repository against the Canonical Charm Tech baseline that the 26.10 +cycle distilled from SSDLC (SEC0023-SEC0061), the Astral OSS-security review, +and per-tool measurement work. It emits a JSON report of findings and can +apply the mechanical fixes. + +This is the deterministic half of a pair. The other half is the +`charm-tech-baseline` skill in +[`canonical/charm-tech`](https://github.com/canonical/charm-tech), which is +what an agent reads: when a check applies, what a finding means, which +decisions are already settled, and which tools were measured and skipped. +The split is deliberate. Prose that an agent reads belongs next to the other +skills; code that has to be run, tested and linted belongs here, where it +gets a lockfile and CI. + +## Use + +```shell +uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=charm-tech-baseline" \ + charm-tech-baseline check --tier=product +``` + +- `check` runs every check that applies to the tier and prints one JSON + report. `--only=security-md,dependabot` narrows it; `--format=markdown` + is for reading rather than for parsing. +- `detect-tier` prints `product`, `canonical`, `personal` or `unknown`, + which is what `check` does for itself when `--tier` is not given. +- `fix ` applies one mechanical fix, for example + `fix add-code-of-conduct`. +- `list` prints the check and fix names. + +Every check reports one of `pass`, `fail`, `na` or `unknown`. `unknown` means +the answer lives somewhere this cannot see - a Drive sheet, a spreadsheet - +and needs a person to look; it is not a quieter `pass`. + +## Layout + +| path | what it is | +|---|---| +| `checks/` | one module per control, each with a `CHECK_ID` and a `main()` | +| `fixes/` | one module per mechanical remediation | +| `assets/` | templates the fixes copy, and the per-repo AGENTS.md question batteries | +| `common.py` | exit codes, tier matching, result emission | +| `tier.py` | tier detection from the origin remote, resolving forks to upstream | + +Checks are imported and called in process by the runner rather than being +shelled out to, so the report is assembled without a round trip through JSON. +A check invoked on its own still prints its own single-line result, which is +how the tests drive them. + +Adding a check means adding a module to `checks/` with a `CHECK_ID` and a +`main()` that calls `emit_check` exactly once. The runner finds it, and no +registry needs updating. + +## Developing + +```shell +uv sync --group unit +uv run pytest +``` diff --git a/charm-tech-baseline/pyproject.toml b/charm-tech-baseline/pyproject.toml new file mode 100644 index 0000000..e910470 --- /dev/null +++ b/charm-tech-baseline/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "charm-tech-code-charm-tech-baseline" +version = "0.1.0" +description = "Audit a repository against the Canonical Charm Tech baseline." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# PyYAML only. Three checks parse YAML that a regex cannot read honestly - +# dependabot cooldowns, workflow env scoping, and the question batteries - and +# each of those carried its own `# /// script` dependency block when they were +# standalone scripts. Everything else is stdlib, and `gh` and `git` are called +# as subprocesses rather than through a library. +dependencies = ["pyyaml"] + +[project.scripts] +charm-tech-baseline = "charm_tech_code.charm_tech_baseline:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# Ruff configuration is at the root of the monorepo, deliberately not repeated +# here: ruff uses the closest config it finds rather than merging, so a +# [tool.ruff] block in this file would silently override the shared one. diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/__init__.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/__init__.py new file mode 100644 index 0000000..0f70913 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/__init__.py @@ -0,0 +1,9 @@ +"""Audit a repository against the Canonical Charm Tech baseline. + +The agent-facing half of this lives in the `charm-tech-baseline` skill in +`canonical/charm-tech`; this package is the deterministic half it drives. +""" + +from .cli import main + +__all__ = ['main'] diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/AGENTS.md.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/AGENTS.md.template new file mode 100644 index 0000000..2615629 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/AGENTS.md.template @@ -0,0 +1,40 @@ +# AGENTS.md + + + +## What this repo is + +{{REPO_DESCRIPTION_ONE_SENTENCE}} + +## Dev setup + +```bash +{{SETUP_COMMANDS}} +``` + +## Tests + +```bash +{{TEST_COMMANDS}} +``` + +## Lint + +```bash +{{LINT_COMMANDS}} +``` + +## Conventions + +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/). +- PRs are reviewed before merge; CI must pass. +- For deeper guidance see [{{DEPTH_LINK_TITLE}}]({{DEPTH_LINK}}). + +## Security + +See [SECURITY.md](SECURITY.md). diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CODE_OF_CONDUCT.md b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0345021 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +This project follows the [Ubuntu Code of Conduct](https://ubuntu.com/community/ethos/code-of-conduct). + +Concerns and reports go to the [Ubuntu Community Council](https://wiki.ubuntu.com/CommunityCouncil), which administers the CoC's reporting and enforcement process. See the [Ubuntu Code of Conduct](https://ubuntu.com/community/ethos/code-of-conduct) for details. diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CONTRIBUTING.md.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CONTRIBUTING.md.template new file mode 100644 index 0000000..0474f8c --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/CONTRIBUTING.md.template @@ -0,0 +1,66 @@ +We welcome contributions to this project! + +Before working on changes, please consider [opening an issue](https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/issues) explaining your use case. If you would like to chat with us about your use cases or proposed implementation, you can reach us on [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com) or [Discourse](https://discourse.charmhub.io/). + + + +# AI + +You're welcome to submit pull requests that are partly or entirely generated using generative AI tools. However, you must review the code yourself before moving the PR out of draft -- by submitting the PR, you are claiming personal responsibility for its quality and suitability. If you are not capable of reviewing the PR, please do not submit it (maybe you'd like to open an issue instead). PRs that are clearly (co-)authored by tools will be closed without review unless there is a human author that claims responsibility for the PR. + +Please do not use tools (such as GitHub Copilot) to provide PR reviews. The Charm Tech team also has access to these tools, and will use them when appropriate. + +# Pull requests + +Changes are proposed as [pull requests on GitHub](https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/pulls). + +- Work on a branch in your own fork. +- Sequence your commits logically if possible. But don't worry too much -- we'll squash to `main` after review. +- Don't force-push after review has started. +- Follow [conventional commit style](https://www.conventionalcommits.org/en/) for the PR title (not required for individual commits). + +The allowed PR-title types — enforced by `.github/workflows/validate-pr-title.yaml` — are: + +`chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `test` + +Examples: + +- feat: add support for X +- fix!: correct the type hinting for config data +- docs: clarify how to use Y +- ci: tighten the publish workflow + +We consider this project too small to use scopes, so we don't use them. + +## Branch updates + +Before you ask for review, please rebase your branch onto `main` so that your changes will merge cleanly. + +If you need to bring in the latest changes from `main` after the review has started, please use a merge commit. + +# Releasing + + + diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/SECURITY.md.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/SECURITY.md.template new file mode 100644 index 0000000..6674b9c --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/SECURITY.md.template @@ -0,0 +1,40 @@ +# Security policy + +## Supported versions + + + +## Reporting a vulnerability + +Please provide a description of the issue, the steps you took to +create the issue, affected versions, and, if known, mitigations for +the issue. + +The easiest way to report a security issue is through [GitHub's +security advisories for this project](https://github.com/{{REPO}}/security/advisories/new). +See [Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for instructions on using the feature. + +You may also send email to {{CONTACT}}. If you want to encrypt your +email, follow [Canonical's reporting instructions](https://ubuntu.com/security/disclosure-policy#contact-us). + +If you have a deadline for public disclosure, please let us know. Our +vulnerability management team intends to respond within 3 working days +of your report. This project aims to resolve all vulnerabilities +within 90 days. + +The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) +contains more information about what you can expect when you contact +us, and what we expect from you. + +To stay informed about vulnerabilities, watch: + +- The [GitHub Security Advisories for `{{REPO}}`](https://github.com/{{REPO}}/security/advisories). +- The project's release history. +- Relevant [Ubuntu Security Notices](https://ubuntu.com/security/notices) when a vulnerability + also affects an Ubuntu-packaged component. diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/check-conventional-pr-title.py.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/check-conventional-pr-title.py.template new file mode 100644 index 0000000..8efc09a --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/check-conventional-pr-title.py.template @@ -0,0 +1,87 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Check that a PR title follows the Conventional Commits specification. + +Reads the PR title from the PR_TITLE environment variable. +Exits with a non-zero status and prints an error message if the title is invalid. + +Reference: https://www.conventionalcommits.org/en/v1.0.0/ + +This repo defines a restricted set of commit types and disallows scopes in PR titles. +""" + +from __future__ import annotations + +import os +import re +import sys + +_TYPES = frozenset({ + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'test', +}) + +# [optional scope][optional !]: +_PATTERN = re.compile( + r'^(?P[A-Za-z]+)' # lower-case only, but let this be validated by _TYPES + r'(?:\((?P[^()]+)\))?' + r'(?P!)?' + r': ' + r'(?P.+)$' +) + +# Adjust this URL when copying into a new repo — point at /CONTRIBUTING.md#pull-requests. +_HELP_URL = 'https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/blob/main/CONTRIBUTING.md#pull-requests' + + +def _main() -> None: + title = os.environ.get('PR_TITLE', '').strip() + if not title: + print('PR_TITLE environment variable is not set or empty.', file=sys.stderr) + sys.exit(1) + + match = _PATTERN.match(title) + if not match: + print( + f'PR title does not follow Conventional Commits format.\n' + f'Expected: [!]: \n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + scope = match.group('scope') + if scope is not None: + print( + f'Scopes must not be used in PR titles.\n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + commit_type = match.group('type') + if commit_type not in _TYPES: + print( + f'Invalid type {commit_type!r} in PR title.\n' + f'Valid types: {", ".join(sorted(_TYPES))}\n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + print(f'OK: {title!r}') + + +if __name__ == '__main__': + _main() diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/dependabot.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/dependabot.yaml.template new file mode 100644 index 0000000..340337d --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/dependabot.yaml.template @@ -0,0 +1,100 @@ +# Routine version-update sweeps only. CVE patches are raised by the +# repo-level "Dependabot security updates" toggle (managed in +# canonical-repo-automation: features.dependabot_security_updates = true), +# which is event-driven and does not honour the schedule below. +# +# Canonical shape per OP0xx (Dependabot config conventions for Charm Tech +# repos). Customise the ecosystem set to match this repo: keep github-actions, +# then pick ONE of the uv / gomod / pip blocks below. Delta from the canonical +# shape (per-repo `groups`, extra directories, etc.) belongs in the spec. +version: 2 + +updates: + # GitHub Actions: routine lane (monthly, single grouped PR) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + open-pull-requests-limit: 100 + commit-message: + prefix: "chore" + cooldown: + default-days: 7 + groups: + actions: + patterns: + - "*" + + # Python (uv): routine lane (monthly, grouped along three seams). + # For a pip repo, swap `package-ecosystem: "uv"` → `"pip"`. For a Go + # repo, delete this whole block and uncomment the gomod block below. + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + open-pull-requests-limit: 100 + commit-message: + prefix: "chore" + cooldown: + default-days: 7 + semver-major-days: 14 + groups: + # Charm Tech's own releases (we trust the release). + # Prune to whatever this repo actually depends on. + charm-tech: + patterns: + - "ops" + - "ops-scenario" + - "ops-tracing" + - "jubilant" + - "pytest-jubilant" + # Linters / type-checkers / formatters. Majors ride along; we do not + # pin these and a major bump is low-risk to review in a batch. + dev-tooling: + patterns: + - "ruff" + - "pyright" + - "ty" + - "codespell" + - "coverage" + - "pre-commit" + - "types-*" + # Test runner + other shared test deps. + test-deps: + patterns: + - "pytest" + - "pytest-*" + # Everything else, minor + patch only. A runtime MAJOR falls through + # to its own ungrouped PR so it never silently rides a patch bundle. + runtime: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Go modules: routine lane (monthly, single grouped PR; a runtime + # major falls through to its own ungrouped PR). Uncomment for Go repos. + # - package-ecosystem: "gomod" + # directory: "/" + # schedule: + # interval: "monthly" + # labels: + # - "dependencies" + # open-pull-requests-limit: 100 + # commit-message: + # prefix: "chore" + # cooldown: + # default-days: 7 + # semver-major-days: 14 + # groups: + # gomod: + # patterns: + # - "*" + # update-types: + # - "minor" + # - "patch" diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/api_demo_server.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/api_demo_server.yaml new file mode 100644 index 0000000..a3d0fea --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/api_demo_server.yaml @@ -0,0 +1,127 @@ +# Question battery for canonical/api_demo_server AGENTS.md. +# Schema: ../../references/question-batteries.md +schema_version: 1 +repo: api_demo_server +upstream: canonical/api_demo_server +source: + agents_md_ref: chore/agents-md + agents_md_sha: 64de5286216bcae8e88022e340da0e8ee791f797 + agents_md_sha256: 2e9924552dc340e268f425606e62d23e7e84287eaab42aecba9b1c415a8a3266 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (api_demo_server table) + seeded_on: 2026-08-19 + +entries: + - id: type-checker + question: Which type checker does this repo use? + classification: override + source_line: >- + **Type checking uses [`ty`](https://github.com/astral-sh/ty)**, not mypy or + pyright; it's pinned in the `dev` dependency group and run via `make lint`. + answer: + grade: keywords + require: + - ty + reject: + - mypy + - pyright + verify: + - kind: text_in_file + file: pyproject.toml + pattern: '"ty>=' + - kind: text_in_file + file: Makefile + pattern: ty check + ci_verifiable: true + note: >- + An agent assumes mypy or pyright. `ty` is recent enough that this is a + confident-wrong-answer case rather than a slow-derivation one. + + - id: no-unit-tests + question: Where are this repo's unit tests, and what does `make integration` actually do? + classification: cache + source_line: >- + There are no unit tests — `make integration` (`.scripts/integration-test.sh`) + brings the stack up with `docker compose`, exercises the create/add/list + endpoints over HTTP, and tears it down. + answer: + grade: judgement + rubric: >- + A correct reply must say there are no unit tests, and that the only test + target is a docker-compose smoke test over HTTP. A reply that merely + names `make integration` has not saved the fruitless hunt for a unit + suite, which is the entire value of the line — and keyword grading + cannot tell those two replies apart. + verify: + - kind: path_exists + path: .scripts/integration-test.sh + - kind: text_in_file + file: Makefile + pattern: "^integration:" + ci_verifiable: false + gated_by: Docker — no daemon in the check sandbox, and none in the Layer 1 runner + + - id: exact-pinned-runtime-deps + question: How are this repo's runtime dependencies versioned, and may you relax them? + classification: override + source_line: >- + **Runtime deps are exact-pinned** in `pyproject.toml` (e.g. `fastapi==…`); + keep them pinned and let Dependabot bump them. + answer: + grade: keywords + require: + - pin + - Dependabot + verify: + - kind: text_in_file + file: pyproject.toml + pattern: fastapi==\d + ci_verifiable: true + note: >- + The failure this prevents is an agent "helpfully" loosening `==` to `>=` + during an unrelated change. Nothing in CI would reject that, so the line + is the only guard. + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "make lint # ruff check; ruff format --diff; ty check" + answer: + grade: command + expect: make lint + verify: + - kind: text_in_file + file: Makefile + pattern: "^lint:" + ci_verifiable: true + + - id: format-command + question: What command formats this repo? + classification: cache + source_line: "make format # uv run ruff format; ruff check --fix" + answer: + grade: command + expect: make format + verify: + - kind: text_in_file + file: Makefile + pattern: "^format:" + ci_verifiable: true + note: >- + Verifiable per scope decisions §1 — tree-mutating commands are run, then + asserted diff-clean and restored, rather than gated. Settled but not yet + implemented in agents-md-content.py, which still routes `make format` + environment-gated. + + - id: integration-command + question: What command runs this repo's tests? + classification: cache + source_line: "make integration # docker compose up + curl smoke checks (needs Docker)" + answer: + grade: command + expect: make integration + verify: + - kind: text_in_file + file: .github/workflows/integration-test.yaml + pattern: integration + ci_verifiable: false + gated_by: Docker daemon diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charm-ubuntu.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charm-ubuntu.yaml new file mode 100644 index 0000000..502b9b9 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charm-ubuntu.yaml @@ -0,0 +1,143 @@ +# Question battery for canonical/charm-ubuntu AGENTS.md. +# Schema: ../../references/question-batteries.md +schema_version: 1 +repo: charm-ubuntu +upstream: canonical/charm-ubuntu +source: + agents_md_ref: chore/agents-md + agents_md_sha: 6350aa338ba5a9b7fcfc0131cf44fa4576c14d16 + agents_md_sha256: f874b13341c375e0675b2d2df402466604d866d2099e3c172271c85a3654bd19 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (charm-ubuntu table) + seeded_on: 2026-08-19 + +entries: + - id: formatter-and-linter + question: Which formatter and linter does this repo use, and at what line length? + classification: override + source_line: >- + **`black` and `flake8`, not `ruff`** — line length 88. + answer: + grade: keywords + require: + - black + - flake8 + - "88" + reject: + - ruff + verify: + - kind: text_in_file + file: tox.ini + pattern: ^\s*black$ + - kind: text_in_file + file: tox.ini + pattern: ^\s*flake8$ + - kind: text_in_file + file: tox.ini + pattern: max-line-length = 88 + ci_verifiable: true + note: >- + The exemplar override for the whole scheme: an agent reaches for ruff + unprompted, and ruff is not installed here, so the mistake costs a failed + lint run rather than a wrong-but-working result. + + - id: test-pythonpath + question: How do this repo's tests import the charm module? + classification: override + source_line: Tests set `PYTHONPATH=src` so `import charm` resolves. + answer: + grade: keywords + require: + - PYTHONPATH + - src + verify: + - kind: text_in_file + file: tox.ini + pattern: PYTHONPATH=\{toxinidir\}/src + ci_verifiable: true + + - id: python-floor + question: Which Python versions must this charm's code stay compatible with? + classification: override + source_line: >- + **Wide Python support:** CI lints and unit-tests on 3.6, 3.8, 3.10, and 3.12. + Keep `src/charm.py` compatible with 3.6. + answer: + grade: keywords + require: + - "3.6" + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: "'3\\.6'" + ci_verifiable: true + note: >- + Per the design doc this override is only ever evicted by the constraint + disappearing — i.e. by 3.6 leaving the CI matrix this entry watches, not + by an eval result. + + - id: ops-api-pin + question: Which version of `ops` does this charm target? + classification: override + source_line: >- + **`ops` is pinned to `>=1.0,<2.0`** (`requirements.txt`) — this charm tracks + the 1.x API, not current `ops`. + answer: + grade: keywords + require: + - ">=1.0,<2.0" + verify: + - kind: text_in_file + file: requirements.txt + pattern: ops>=1\.0,<2\.0 + ci_verifiable: true + note: >- + An agent writes current-ops idioms by default; on the 1.x API those are + not merely stylistically off, they do not exist. + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "tox -e lint # flake8 + black --check (this charm uses black, not ruff)" + answer: + grade: command + expect: tox -e lint + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:lint\] + ci_verifiable: true + + - id: unit-test-command + question: What command runs the unit tests? + classification: cache + source_line: "tox -e unit # unit tests under tests/unit" + answer: + grade: command + expect: tox -e unit + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:unit\] + ci_verifiable: true + + - id: integration-prerequisites + question: What does running the integration tests require? + classification: cache + source_line: "tox -e integration # deploys to LXD; needs juju and charmcraft (packs the charm)" + answer: + grade: keywords + require: + - LXD + - juju + - charmcraft + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:integration\] + - kind: path_exists + path: .charmcraft-channel + ci_verifiable: false + gated_by: >- + LXD + a Juju controller + charmcraft (channel pinned in + .charmcraft-channel) — the file's own prose says it cannot run without + that environment diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charmlibs.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charmlibs.yaml new file mode 100644 index 0000000..738d9d8 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/charmlibs.yaml @@ -0,0 +1,432 @@ +# Question battery for canonical/charmlibs AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/), on +# feat/add-charm-tech-baseline-skill in tonyandrewmeyer/charm-tech. +# +# NOTE: this file is staged here, not in the skill repo, because this +# routine cannot push to tonyandrewmeyer/charm-tech (not attached as a +# source). It needs moving into +# skills/engineering/charm-tech-baseline/assets/question-batteries/ +# once the skill lands in its permanent home — see PROGRESS.md. +schema_version: 1 +repo: charmlibs +upstream: canonical/charmlibs +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: 53d9f2460529c0ce35c270d0aae795d590cae4af + agents_md_sha256: 5b592217687533d399dcadb2fc0f0c46b0fb30938ecb2a3023ccbe35bd3c61ad + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, charmlibs trim) + seeded_on: 2026-08-26 + +entries: + - id: check-command + question: What single command runs the standard pre-commit check for a charmlibs library, and what does it do? + classification: cache + source_line: >- + This runs `just lint `, `just unit `, and `just docs + html `. **Run this before every commit on the affected + package.** + answer: + grade: keywords + require: + - just check + - lint + - unit + - docs + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: '"""`lint`, `unit` test, and build the `docs` for a package\."""' + ci_verifiable: true + + - id: package-arg-format + question: When a charmlibs `just` command takes a `` argument, what do you pass for it? + classification: cache + source_line: >- + The `` argument is the path from the repo root, e.g. `pathops` + or `interfaces/tls-certificates`. + answer: + grade: keywords + require: + - path + - repo root + verify: + - kind: path_exists + path: pathops + - kind: path_exists + path: interfaces/tls-certificates + ci_verifiable: true + + - id: lint-command + question: What does `just lint ` run in charmlibs? + classification: cache + source_line: "`just lint ` | ruff + pyright" + answer: + grade: keywords + require: + - ruff + - pyright + reject: + - codespell + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: 'Run fast linting \(`ruff`\) and static analysis \(`pyright`\) for a package\.' + ci_verifiable: true + note: >- + Corrected during this trim: the pre-trim file said "ruff + codespell + + pyright". codespell is declared in pyproject.toml's fast-lint + dependency group but is not invoked by `_fast_lint()`, `lint()`, or any + current workflow — confirmed by grepping .scripts/, the justfiles, and + .github/workflows/ for an actual codespell invocation and finding none. + Layer 1 has no check for this (it is prose semantics, not a missing + tool/path/symbol), so this was only found by reading the code — the + same category as concierge's "Configuration Priority" finding. + + - id: fast-lint-command + question: What does `just fast-lint [path]` run, and how is it different from `just lint`? + classification: cache + source_line: "`just fast-lint [path]` | ruff only, across the whole repo or a specific path" + answer: + grade: keywords + require: + - ruff + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: "Run `ruff`, failing afterwards if any errors are found\\." + ci_verifiable: true + + - id: never-run-functional-on-host + question: Where should you run charmlibs functional tests, and why not directly on your machine? + classification: override + source_line: >- + **Do not run functional tests directly on the host.** Use Workshop + instead (see below). + answer: + grade: keywords + require: + - Workshop + - host + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: isolated container instead of running them directly on your host + ci_verifiable: true + note: >- + Destructive-relevant override: functional tests may install or remove + system packages, matching the design doc's "wrong without, right with" + case for irreversible-local-action prohibitions (concierge's + exec.Command() ban is the precedent). + + - id: workshop-image-names + question: Which Workshop image do you use to run functional tests against Ubuntu 24.04? + classification: cache + source_line: >- + workshop exec noble -- sudo just functional # Ubuntu + 24.04 + answer: + grade: command + expect: workshop exec noble -- sudo just functional + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "workshop exec resolute -- sudo just functional" + ci_verifiable: true + note: >- + The resolute/noble/jammy → 26.04/24.04/22.04 mapping is not written + down anywhere else in the tree — cache value is the lookup, not the + existence of Workshop itself. + + - id: no-direct-uv-add + question: How do you add a dependency to a charmlibs library, and why not `uv add` directly? + classification: override + source_line: >- + **Always use `just add ` instead of calling `uv add` + directly.** This applies repo-level version constraints from + `test-requirements.txt`, which is necessary to keep the lockfile + consistent: + answer: + grade: keywords + require: + - just add + - test-requirements.txt + reject: + - uv add + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: rather than calling `uv add` directly + ci_verifiable: true + + - id: test-type-directory-gate + question: How do you make charmlibs skip a test type (e.g. functional) for a library entirely? + classification: cache + source_line: >- + A test type is only executed if the corresponding `tests/` + subdirectory exists. Remove a directory to skip that test type + entirely. + answer: + grade: keywords + require: + - tests/ + - remove + verify: + - kind: text_in_file + file: .github/workflows/test-package.yaml + pattern: hashFiles\(format\('\{0\}/tests/integration/pack\.sh' + ci_verifiable: true + note: >- + Anchored on the integration case (CI gates the pack/deploy jobs on + `hashFiles(.../tests/integration/pack.sh)`); unit and functional follow + the same directory-presence pattern in .scripts/just.py's package + discovery but without one single grep-able line as clean as this one. + + - id: pr-title-becomes-commit + question: What becomes the commit message when a charmlibs pull request is merged? + classification: override + source_line: >- + **PRs are squash-merged.** The PR title becomes the single commit + message on `main`. + answer: + grade: keywords + require: + - PR title + - squash + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "PRs are squash-merged, so your PR title becomes the single commit message" + ci_verifiable: true + + - id: pr-title-scope-convention + question: When a charmlibs PR affects a single library, what scope do you use in the conventional-commit PR title? + classification: override + source_line: >- + When a PR affects a single library, use the distribution package name + without the leading `charmlibs-` as the scope + answer: + grade: keywords + require: + - distribution package name + - charmlibs- + verify: + - kind: text_in_file + file: .github/workflows/conventional-pr-title.yaml + pattern: conventional-pr-title + - kind: text_in_file + file: CONTRIBUTING.md + pattern: without the leading `charmlibs-` + ci_verifiable: true + + - id: one-library-per-pr + question: How many libraries should a single charmlibs PR normally touch, and why? + classification: override + source_line: >- + **One PR should normally touch only one library.** The CI uses changed + files to determine which packages to test and, on merge, which + packages to publish. + answer: + grade: keywords + require: + - one library + - changed files + verify: + - kind: path_exists + path: .github/get-changed.py + - kind: text_in_file + file: .github/workflows/ci.yaml + pattern: get-changed\.py + ci_verifiable: true + + - id: changelog-gate + question: What must you update in the same PR as a non-dev version bump, and what happens if you don't? + classification: override + source_line: >- + When bumping to a non-dev version, you **must** also update + `CHANGELOG.md`. CI will block the merge otherwise. + answer: + grade: keywords + require: + - CHANGELOG.md + - block + verify: + - kind: text_in_file + file: .github/workflows/ci.yaml + pattern: CHANGELOG\.md must be updated before merging + ci_verifiable: true + + - id: dev-version-exclusion + question: How do you land in-progress work on a library without triggering a release? + classification: cache + source_line: >- + Dev versions (`X.Y.Z.devN`) are excluded from release CI — safe for + in-progress work. + answer: + grade: keywords + require: + - dev + - release + verify: + - kind: text_in_file + file: .scripts/ls.py + pattern: Excludes changes where the new version is a dev version\. + ci_verifiable: true + + - id: interface-naming + question: What determines the directory name of an interface library in charmlibs? + classification: override + source_line: >- + Live under `interfaces//`, named exactly as the + interface name appears in `charmcraft.yaml`. + answer: + grade: keywords + require: + - charmcraft.yaml + verify: + - kind: text_in_file + file: interfaces/tls-certificates/tests/integration/charms/provider/charmcraft.yaml + pattern: "interface: tls-certificates" + - kind: text_in_file + file: .scripts/just.py + pattern: "The project name should be the canonical interface" + ci_verifiable: true + + - id: interface-no-functional-tests + question: Which of the three test types do charmlibs interface libraries typically NOT have, and why? + classification: cache + source_line: >- + Typically have unit and integration tests but no functional tests (all + meaningful interaction is through Juju). + answer: + grade: judgement + rubric: >- + A correct reply must say interface libraries typically lack + functional tests, and that this is because interfaces only interact + through Juju relation data, not real external processes — the thing + functional tests exercise. A reply that only names "no functional + tests" without the reason has not saved the agent from later adding + a needless functional/ directory. + verify: + - kind: none + reason: >- + A repo-wide absence claim (no interface library currently has a + tests/functional/ directory) is not expressible with the existing + verify kinds, which assert presence, not absence. Confirmed by + hand: `find interfaces -maxdepth 3 -type d -name functional` + (excluding the .example/.template scaffolds) returns nothing + across all 74 interfaces. + ci_verifiable: false + gated_by: >- + no verify kind for "does not exist anywhere in a set of directories" — + would need a repo-wide scan, which the battery schema deliberately + keeps out of scope (see references/question-batteries.md's note that + assertions are static single-file checks) + + - id: init-interface-scaffold + question: How do you scaffold a new interface library in charmlibs? + classification: cache + source_line: "Use `just init --interface` to scaffold." + answer: + grade: command + expect: just init --interface + verify: + - kind: text_in_file + file: .scripts/just.py + pattern: "'--interface'," + ci_verifiable: true + + - id: docstrings-appear-verbatim + question: What should you keep in mind when writing or editing a docstring in a charmlibs library's `__init__.py`? + classification: override + source_line: >- + remember they appear verbatim in the published reference at + [canonical.com/juju/docs/charmlibs](https://canonical.com/juju/docs/charmlibs). + Keep them informative for library users, not implementation notes. + answer: + grade: keywords + require: + - verbatim + - published + reject: + - implementation + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: appears verbatim in the published reference + ci_verifiable: true + + - id: no-blockquote-read-more + question: >- + What markdown form does charmlibs use for a "Read more" / + "See also" cross-reference line in docs, and what form should you + avoid? + classification: override + source_line: >- + Don't use block quotes (`>`) for "Read more", "See also", or similar + cross-reference sections. Instead, use bare text + answer: + grade: keywords + require: + - bare text + reject: + - "> Read more" + verify: + - kind: none + reason: >- + No repo-level linter enforces this (a .docs/ vale config exists + but its rule set was not confirmed to cover this specific case); + the only evidence is that existing docs pages (e.g. + .docs/tutorial.md) consistently use the bare-text form in + practice, which a text_in_file assertion can't distinguish from + coincidence. + ci_verifiable: false + gated_by: no confirmed linter rule; a style convention enforced by review, not tooling + + - id: no-unnecessary-refactoring + question: >- + Why should you avoid adding features or refactoring code beyond what + a charmlibs task asks for? + classification: override + source_line: >- + **Don't add unnecessary features or refactor code beyond what's + asked** — this is a multi-team monorepo with careful versioning; + unintended public API changes require major version bumps. + answer: + grade: judgement + rubric: >- + A correct reply must connect the prohibition to its actual cost: + this is a multi-team monorepo, so an unintended public API change on + a shared library forces a major version bump that affects every + consuming team, not just a local style preference. + verify: + - kind: none + reason: >- + No file states or enforces a scope-discipline rule; CI does not + gate on "did this PR change more than it needed to". Behavioral + norm only. + ci_verifiable: false + gated_by: not represented in the tree at all — an editorial norm, not a checked rule + + - id: integration-test-tooling + question: >- + What tool do charmlibs integration tests use to drive Juju, and what + sets up the Juju environment in CI? + classification: cache + source_line: >- + Integration tests use [Jubilant](https://canonical.com/juju/docs/jubilant/) + as the Juju test client, and CI provisions the Juju environment with + [Concierge](https://raw.githubusercontent.com/canonical/concierge/refs/heads/main/README.md). + answer: + grade: keywords + require: + - Jubilant + - Concierge + verify: + - kind: text_in_file + file: pathops/tests/integration/conftest.py + pattern: import jubilant + - kind: text_in_file + file: .github/workflows/test-package.yaml + pattern: concierge + ci_verifiable: true diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/concierge.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/concierge.yaml new file mode 100644 index 0000000..6e6b5f5 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/concierge.yaml @@ -0,0 +1,195 @@ +# Question battery for canonical/concierge AGENTS.md. +# Schema: question-batteries.md in the charm-tech-baseline skill +# (skills/engineering/charm-tech-baseline/references/), on +# feat/add-charm-tech-baseline-skill in tonyandrewmeyer/charm-tech. +# +# NOTE: this file is staged here, not in the skill repo, because this +# routine cannot push to tonyandrewmeyer/charm-tech (not attached as a +# source). It needs moving into +# skills/engineering/charm-tech-baseline/assets/question-batteries/ +# once the skill lands in its permanent home — see PROGRESS.md. +schema_version: 1 +repo: concierge +upstream: canonical/concierge +source: + agents_md_ref: chore/agents-md-trim + agents_md_sha: b6d4afb8045ee93a29039eca982c865788f854a9 + agents_md_sha256: af058c9133d01163ecc7c8c7915559064d364f3cf756735ab03b7ca00a3e7fc7 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (implementation follow-up 4, concierge trim) + seeded_on: 2026-08-26 + +entries: + - id: build-binary + question: What command builds the concierge binary? + classification: cache + source_line: go build + answer: + grade: command + expect: go build + verify: + - kind: path_exists + path: main.go + ci_verifiable: true + + - id: snapshot-release + question: How do you build a local snapshot release with goreleaser? + classification: cache + source_line: goreleaser build --clean --snapshot + answer: + grade: command + expect: goreleaser build --clean --snapshot + verify: + - kind: path_exists + path: .goreleaser.yaml + ci_verifiable: false + gated_by: >- + goreleaser must be installed first (CI does `sudo snap install --classic + goreleaser`) — not present in the check sandbox + + - id: unit-tests + question: What command runs concierge's unit tests? + classification: cache + source_line: go test ./... + answer: + grade: command + expect: go test ./... + verify: + - kind: text_in_file + file: .github/workflows/_tests.yaml + pattern: go test -v -race \./\.\.\. + ci_verifiable: true + note: >- + CI runs `go test -v -race ./...` (adds -race and -v); AGENTS.md + documents the simpler form. Not stale — both are valid invocations of + the same package target — but noted since it's a real, if harmless, + divergence from what CI actually runs. + + - id: integration-tests-lxd + question: How do you run all of concierge's integration tests locally? + classification: cache + source_line: "spread -v lxd:" + answer: + grade: command + expect: "spread -v lxd:" + verify: + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "spread -v lxd:" + ci_verifiable: false + gated_by: LXD — needed to actually run the suite + + - id: integration-tests-single + question: How do you run one specific spread integration test? + classification: cache + source_line: "spread -v lxd:ubuntu-24.04:tests/juju-model-defaults" + answer: + grade: command + expect: "spread -v lxd:ubuntu-24.04:tests/juju-model-defaults" + verify: + - kind: path_exists + path: tests/juju-model-defaults + - kind: text_in_file + file: CONTRIBUTING.md + pattern: "spread -v lxd:ubuntu-24\\.04:tests/juju-model-defaults" + ci_verifiable: false + gated_by: LXD — needed to actually run the suite + + - id: integration-tests-github-ci + question: >- + How do you run concierge's integration tests on a pre-provisioned + machine instead of LXD VMs? + classification: cache + source_line: "spread -v github-ci:" + answer: + grade: command + expect: "spread -v github-ci:" + verify: + - kind: text_in_file + file: .github/workflows/_tests.yaml + pattern: 'spread -v "github-ci:ubuntu-24\.04:tests/\$\{SUITE\}"' + ci_verifiable: false + gated_by: >- + a github-ci-capable pre-provisioned host, and the spread binary + (installed via `go install`, not present in the check sandbox) + + - id: sudo-required + question: Does the concierge binary need to be run with elevated privileges? + classification: cache + source_line: >- + Note: The binary must be run with `sudo` for most operations since it + installs system packages and configures providers. + answer: + grade: keywords + require: + - sudo + verify: + - kind: text_in_file + file: README.md + pattern: sudo concierge prepare + ci_verifiable: true + + - id: no-direct-exec-command + question: >- + How must concierge's Go code invoke external commands, and why not + call exec.Command() directly? + classification: override + source_line: >- + **Never call `exec.Command()` directly.** Build commands with + `system.NewCommand(executable, []string{arg1, arg2})`, passing each + argument as a separate slice element (no string concatenation) — the + binary runs as root, so this avoids command injection. + answer: + grade: keywords + require: + - system.NewCommand + verify: + - kind: text_in_file + file: internal/system/command.go + pattern: func NewCommand + ci_verifiable: true + note: >- + Security-relevant: concierge runs as root, so an agent building a + command with exec.Command() and string-concatenated arguments opens a + command-injection surface. This is the design doc's own worked + "Symbol in path" example (system.NewCommand in + internal/system/command.go) — see scope decisions §4. + + - id: runtime-config-cache + question: >- + Where does `prepare` record what it provisioned, and what reads that + record? + classification: cache + source_line: >- + During `prepare`, the merged configuration (including all overrides) + is saved to `~/.cache/concierge/concierge.yaml`; `restore` reads this + file to undo exactly what was provisioned. + answer: + grade: keywords + require: + - concierge.yaml + - restore + verify: + - kind: text_in_file + file: internal/concierge/manager.go + pattern: path\.Join\("\.cache", "concierge", "concierge\.yaml"\) + ci_verifiable: true + + - id: snap-refresh-workaround + question: >- + What must you do before refreshing a snap to a different channel in + concierge's LXD provider? + classification: cache + source_line: >- + **Refreshing a snap to a different channel may require stopping it + first.** See `internal/providers/lxd.go` (`workaroundRefresh()`) for + the pattern. + answer: + grade: keywords + require: + - stop + - workaroundRefresh + verify: + - kind: text_in_file + file: internal/providers/lxd.go + pattern: func \(l \*LXD\) workaroundRefresh + ci_verifiable: true diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pebble.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pebble.yaml new file mode 100644 index 0000000..ffefb5b --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pebble.yaml @@ -0,0 +1,183 @@ +# Question battery for canonical/pebble AGENTS.md. +# Schema: ../../references/question-batteries.md +schema_version: 1 +repo: pebble +upstream: canonical/pebble +source: + agents_md_ref: chore/agents-md + agents_md_sha: 40e3936c1a07ea3d2e1b792471d1aeaa2a76aa29 + agents_md_sha256: 44d99acdc5011e872b66633b1e19186f3cae489ebb3de68223203c866a5f044e + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pebble table) + seeded_on: 2026-08-19 + +entries: + - id: unit-tests + question: What command runs pebble's unit tests? + classification: cache + source_line: "go test -race ./... # unit tests (CI runs with -race)" + answer: + grade: command + expect: go test -race ./... + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: go test -race \./\.\.\. + ci_verifiable: true + note: >- + Passes on a non-root runner. As root without PEBBLE_TEST_USER/ + PEBBLE_TEST_GROUP set, servstate.TestUserGroup takes its non-skip branch + and fails — an environment artifact, not staleness (see the 2026-07-28 + Layer 1 build log). + + - id: single-gocheck-suite + question: How do you run just the PebbleSuite gocheck suite? + classification: cache + source_line: "go test ./internals/cli -check.f PebbleSuite # single gocheck suite or test" + answer: + grade: command + expect: go test ./internals/cli -check.f PebbleSuite + verify: + - kind: suite_in_package + suite: PebbleSuite + package: internals/cli + ci_verifiable: true + note: >- + The canonical staleness case. HACKING.md documented this against + ./cmd/pebble long after the suite moved to internals/cli, and cmd/pebble + has no test files at all. + + - id: root-test-env-vars + question: What must be set to run the pebble tests that require root? + classification: cache + source_line: PEBBLE_TEST_USER=$USER PEBBLE_TEST_GROUP=$USER sudo -E -H "$(which go)" test ./... + answer: + grade: keywords + require: + - PEBBLE_TEST_USER + - PEBBLE_TEST_GROUP + - sudo + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: PEBBLE_TEST_USER=\w+ PEBBLE_TEST_GROUP=\w+ + ci_verifiable: false + gated_by: root/sudo — the root-tests job runs as a separate privileged CI job + + - id: integration-build-tag + question: How do you run pebble's integration tests? + classification: cache + source_line: "go test -count=1 -tags=integration ./tests/ # integration tests (build tag)" + answer: + grade: command + expect: go test -count=1 -tags=integration ./tests/ + verify: + - kind: text_in_file + file: tests/main_test.go + pattern: //go:build integration + ci_verifiable: false + gated_by: >- + integration environment — and no workflow runs these at all, so the build + tag is the only anchor available + + - id: no-empty-interface + question: What must you write instead of `interface{}` in this codebase? + classification: override + source_line: CI also rejects any use of `interface{}` — write `any`. + answer: + grade: keywords + require: + - any + verify: + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: Ensure no use of empty interface + ci_verifiable: true + note: >- + The design doc places this gate in lint.yml. It is in tests.yml, in the + `format` job — corrected here. + + - id: message-casing + question: How are error messages and log messages capitalised in pebble? + classification: override + source_line: >- + **Error messages** are lowercase and start with "cannot" (`cannot create log + client: %w`); **log messages** are capitalised and start with "Cannot". + case_sensitive: true + answer: + grade: keywords + require: + - cannot + - Cannot + verify: + - kind: text_in_file + file: STYLE.md + pattern: Start error messages with "cannot" + ci_verifiable: false + gated_by: >- + style convention enforced in review, not by a linter — STYLE.md is the + only anchor + + - id: gocheck-dot-import + question: How is the gocheck package imported in pebble's tests, and what does the repo use instead of stdlib testing assertions? + classification: override + source_line: >- + Tests use [`gopkg.in/check.v1`](https://pkg.go.dev/gopkg.in/check.v1), + dot-imported (`. "gopkg.in/check.v1"`), not the stdlib `testing` assertions. + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: gocheck (gopkg.in/check.v1) + is the assertion library, and it is dot-imported. A reply naming gocheck + without the dot-import misses the load-bearing half — agents avoid dot + imports by default — so a keyword grader on "check.v1" alone would pass + answers that fail the actual test. + verify: + - kind: text_in_file + file: internals/cli/cli_test.go + pattern: '\. "gopkg\.in/check\.v1"' + ci_verifiable: true + + - id: lint-tool-pins + question: Which staticcheck and govulncheck versions does pebble's CI pin? + classification: cache + source_line: go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 && staticcheck ./... + answer: + grade: keywords + require: + - v0.7.0 + - v1.1.4 + verify: + - kind: text_in_file + file: .github/workflows/lint.yml + pattern: staticcheck@v0\.7\.0 + - kind: text_in_file + file: .github/workflows/lint.yml + pattern: govulncheck@v1\.1\.4 + ci_verifiable: true + note: >- + Drift-prone, and already skewed inside the repo: tiobe.yaml pins + staticcheck@v0.6.1 against lint.yml's v0.7.0. That skew is a separate + defect (scope decisions §3) and is not this entry's business — this entry + asserts what AGENTS.md claims is what lint.yml pins. + + - id: cli-help-staleness-gate + question: After changing a pebble CLI command, what must you run so CI does not fail on stale docs? + classification: cache + source_line: >- + After changing a CLI command, run `make cli-help` in `docs/` (CI fails if + the generated CLI reference is stale). + answer: + grade: command + expect: make cli-help + verify: + - kind: text_in_file + file: docs/Makefile + pattern: "^cli-help:" + - kind: text_in_file + file: .github/workflows/tests.yml + pattern: make cli-help + ci_verifiable: true + note: >- + Per scope decisions §1 this runs with a diff-clean assertion and a + restore, rather than being gated as tree-mutating. That decision is + settled but not yet implemented in agents-md-content.py. diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pytest-jubilant.yaml b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pytest-jubilant.yaml new file mode 100644 index 0000000..25e913c --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/question-batteries/pytest-jubilant.yaml @@ -0,0 +1,162 @@ +# Question battery for canonical/pytest-jubilant AGENTS.md. +# Schema: ../../references/question-batteries.md +schema_version: 1 +repo: pytest-jubilant +upstream: canonical/pytest-jubilant +source: + agents_md_ref: chore/agents-md + agents_md_sha: 46276b0fe118266f61ad45f0e1c59116cd884b57 + agents_md_sha256: c00f361f0304fa22a6e6f16da30c015a6085ac8cccf98406ed43cdc54865a589 + seeded_from: roadmap/26.10/repo-setup/agents-md-validation.md (pytest-jubilant table) + seeded_on: 2026-08-19 + +entries: + - id: tox-bootstrap + question: How do you install the task runner this repo's commands go through? + classification: cache + source_line: uv tool install tox --with tox-uv + answer: + grade: command + expect: uv tool install tox --with tox-uv + verify: + - kind: text_in_file + file: .github/workflows/quality_checks.yaml + pattern: uv tool install tox --with tox-uv + ci_verifiable: false + gated_by: >- + installs a tool into the environment — Layer 1 check 2 classifies + `uv tool install` as side-effecting and does not execute it + + - id: lint-command + question: What command lints this repo? + classification: cache + source_line: "tox -e lint # ruff format --check, ruff check, pyright (must pass before pushing)" + answer: + grade: command + expect: tox -e lint + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:lint\] + ci_verifiable: true + + - id: unit-test-command + question: What command runs the unit tests? + classification: cache + source_line: "tox -e unit # unit tests under tests/unit with coverage" + answer: + grade: command + expect: tox -e unit + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:unit\] + ci_verifiable: true + + - id: integration-command + question: What command runs the integration tests? + classification: cache + source_line: "tox -e integration # integration tests under tests/integration" + answer: + grade: command + expect: tox -e integration + verify: + - kind: text_in_file + file: tox.ini + pattern: \[testenv:integration\] + ci_verifiable: false + gated_by: a Juju model and packed charms + + - id: integration-prerequisites + question: What has to be prepared before the integration tests will run locally? + classification: cache + source_line: >- + The integration tests need packed test charms; pack them and set the + `*CHARM_PATH` environment variables they reference before running locally. + answer: + grade: keywords + require: + - pack + - CHARM_PATH + verify: + - kind: text_in_file + file: tox.ini + pattern: CHARM_PATH + - kind: text_in_file + file: tests/integration/conftest.py + pattern: _CHARM_PATH + ci_verifiable: false + gated_by: packed charms — the charms must be built before the env vars mean anything + note: >- + High derivation cost: the variable names are only discoverable by reading + tests/integration/conftest.py, and the pack step is not written down + anywhere else in the tree. + + - id: python-floor + question: What is the minimum Python version this repo must support? + classification: override + source_line: "**Python floor is 3.8** (set by jubilant) — keep code compatible." + answer: + grade: keywords + require: + - "3.8" + verify: + - kind: text_in_file + file: pyproject.toml + pattern: requires-python = ">=3\.8" + ci_verifiable: true + note: >- + The override that matters — an agent left to itself writes 3.10+ syntax. + The floor is inherited from jubilant, so it can move without anything in + this repo changing except the pin this entry watches. + + - id: commit-convention + question: What commit-message convention does this repo use, and are scopes allowed? + classification: override + source_line: >- + **Commits / PR titles:** [Conventional Commits](https://www.conventionalcommits.org/); + no scopes. + answer: + grade: judgement + rubric: >- + A correct reply must establish both halves: Conventional Commits, and + that scopes are not permitted. Keyword grading on "conventional" alone + passes replies that offer `feat(plugin): …`, which is the exact mistake + this line exists to prevent. + verify: + - kind: text_in_file + file: .github/check-conventional-pr-title.py + pattern: disallows scopes + - kind: text_in_file + file: .github/workflows/validate-pr-title.yaml + pattern: check-conventional-pr-title\.py + ci_verifiable: true + note: >- + The design doc calls this line "not derivable from the tree at all + (convention lives in review practice)". That is no longer true: the + validate-pr-title sweep added .github/check-conventional-pr-title.py, + which states and enforces the no-scopes rule. Still an override — an + agent will not read a CI helper script before writing a commit message — + but it is now anchored, and the design doc's rationale needs amending. + + - id: pr-title-becomes-commit + question: What becomes the commit message when a pull request is merged here? + classification: override + source_line: The PR title becomes the squashed commit message. + answer: + grade: keywords + require: + - PR title + - squash + verify: + - kind: none + reason: >- + This is the repo's squash-merge setting, which lives in GitHub's + configuration and is not represented anywhere in the tree. Nothing + here can go stale in a way a file check would notice; it goes stale + when somebody changes the merge method. + ci_verifiable: false + gated_by: GitHub repo settings — readable only via the API, not from a checkout + note: >- + This half of the design doc's row genuinely is un-anchored, unlike the + no-scopes half. Splitting the row was what made that visible. diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbom-secscan.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbom-secscan.yaml.template new file mode 100644 index 0000000..c213a7e --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbom-secscan.yaml.template @@ -0,0 +1,86 @@ +# Reusable workflow: generate SBOMs and run secscan via canonical/sbomber. +# Tier: product. Called from the Publish workflow (see trusted-publishing- +# product.yml.template) after the release is built. +# +# Matrix: one row per artefact type (sdist, wheel). Each row reads a +# corresponding .sbomber-manifest-.yaml at the repo root, prepared +# from sbomber-manifest-{sdist,wheel}.yaml.template. +# +# Runs on Canonical self-hosted runners because sbomber submits to an +# internal service (sbom-request.canonical.com) not reachable from public +# GitHub-hosted runners. +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor with no findings. + +name: SBOM and secscan + +on: + workflow_call: + workflow_dispatch: + +permissions: {} + +jobs: + scan: + strategy: + fail-fast: false + matrix: + manifest: [sdist, wheel] + name: SBOM generation + runs-on: [self-hosted, self-hosted-linux-amd64-jammy-private-endpoint-medium] + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Install apt build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libapt-pkg-dev python3-apt + + - name: Checkout sbomber + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: canonical/sbomber + path: scanner + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Install secscan client + run: | + sudo snap install canonical-secscan-client + sudo snap connect canonical-secscan-client:home system:home + + - name: Prepare artefacts + run: | + cd scanner + ./sbomber prepare ../.sbomber-manifest-${{ matrix.manifest }}.yaml + + - name: Submit artefacts + run: | + cd scanner + ./sbomber submit + + - name: Wait for scans + run: | + cd scanner + ./sbomber poll --wait --timeout 30 + + - name: Download reports + run: cd scanner && ./sbomber download + + - name: Upload reports + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: secscan-report-upload-${{ matrix.manifest }} + path: ./scanner/reports/ + if-no-files-found: error diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-sdist.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-sdist.yaml.template new file mode 100644 index 0000000..2708da5 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-sdist.yaml.template @@ -0,0 +1,24 @@ +# sbomber manifest — sdist artefacts. Committed as .sbomber-manifest-sdist.yaml +# at the repo root. Consumed by sbom-secscan.yaml. +# +# Fill in before use: +# - clients.sbom.email — owner-of-record; a real @canonical.com address. +# - artifacts[] — one entry per sdist the release produces. +# `name` matches the sdist tarball's project name (as it appears in dist/). + +clients: + sbom: + service_url: https://sbom-request.canonical.com + department: charm_engineering + email: REPLACE_WITH_OWNER_EMAIL@canonical.com + team: charm_tech + secscan: {} + +artifacts: + - name: 'REPLACE_WITH_PROJECT_NAME' + type: 'sdist' + compression: 'gz' + ssdlc_params: + name: 'REPLACE_WITH_PROJECT_NAME' + version: '' + channel: 'stable' diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-wheel.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-wheel.yaml.template new file mode 100644 index 0000000..0a18131 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/sbomber-manifest-wheel.yaml.template @@ -0,0 +1,23 @@ +# sbomber manifest — wheel artefacts. Committed as .sbomber-manifest-wheel.yaml +# at the repo root. Consumed by sbom-secscan.yaml. +# +# Fill in before use: +# - clients.sbom.email — owner-of-record; a real @canonical.com address. +# - artifacts[] — one entry per wheel the release produces. +# `name` matches the wheel's project name (as it appears in dist/). + +clients: + sbom: + service_url: https://sbom-request.canonical.com + department: charm_engineering + email: REPLACE_WITH_OWNER_EMAIL@canonical.com + team: charm_tech + secscan: {} + +artifacts: + - name: 'REPLACE_WITH_PROJECT_NAME' + type: 'wheel' + ssdlc_params: + name: 'REPLACE_WITH_PROJECT_NAME' + version: '' + channel: 'stable' diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template new file mode 100644 index 0000000..4fe1577 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template @@ -0,0 +1,71 @@ +# Canonical shape for a Trusted-Publishing-based PyPI release workflow. +# Tier: product (SBOM handled by canonical/sbomber via a reusable secscan job). +# For tier=personal|canonical, use trusted-publishing.yaml.template instead. +# +# Fill in before use: +# - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) +# - Tag pattern under `on.push.tags` +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor with no findings. +# +# This template pairs with: +# - .github/workflows/sbom-secscan.yaml (from sbom-secscan.yaml.template) +# - .sbomber-manifest-sdist.yaml (from sbomber-manifest-sdist.yaml.template) +# - .sbomber-manifest-wheel.yaml (from sbomber-manifest-wheel.yaml.template) +# +# Anti-patterns this template avoids (and zizmor enforces): +# - No password:/username: on pypa/gh-action-pypi-publish (Trusted Publishing). +# - No twine upload step. +# - Every third-party action is SHA-pinned with a version comment. +# - id-token: write + attestations: write scoped to the publish job only. +# - persist-credentials: false on every checkout. +# - enable-cache: false on setup-uv. + +name: Publish + +on: + push: + tags: ['v*'] + +permissions: {} + +jobs: + publish: + name: Build and publish to PyPI (Trusted Publishing) + runs-on: ubuntu-latest + environment: + name: publish-pypi + url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + permissions: + id-token: write # OIDC to PyPI + sigstore for attestations. + attestations: write # Write build-provenance predicate. + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Build sdist and wheel + run: uv build + + - name: Attest build provenance (SLSA) + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: 'dist/*' + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + + secscan: + # SBOM + Canonical secscan (product-tier requirement). + # The reusable workflow generates SBOMs via canonical/sbomber for each + # artefact type in .sbomber-manifest-*.yaml. + uses: ./.github/workflows/sbom-secscan.yaml diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template new file mode 100644 index 0000000..e15b447 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template @@ -0,0 +1,88 @@ +# Canonical shape for a Trusted-Publishing-based PyPI release workflow. +# Tier: personal, canonical (inline CycloneDX SBOM + dual attestation). +# For tier=product, use trusted-publishing-product.yaml.template instead. +# +# Fill in before use: +# - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) +# - Tag pattern under `on.push.tags` (default: v*; operator uses [1-3].*) +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor (which enforces SHA pinning, permissions:{}, +# persist-credentials:false, and the rest of the anti-patterns listed below). +# +# Anti-patterns this template avoids (and zizmor enforces): +# - No password:/username: on pypa/gh-action-pypi-publish (Trusted Publishing). +# - No twine upload step. +# - No floating action tags — every third-party action is SHA-pinned with a +# version comment. +# - id-token: write + attestations: write scoped to this job only. +# - persist-credentials: false on every checkout. +# - enable-cache: false on setup-uv (avoids cache-poisoning of the release). +# +# Two separate attestations are produced: SLSA provenance (how it was built) +# via attest-build-provenance, and a CycloneDX SBOM predicate (what's inside) +# via attest. attest-build-provenance does not accept sbom-path, so the +# SBOM predicate needs its own call. (attest-sbom is deprecated; actions/attest +# is the direct replacement and accepts the same subject-path/sbom-path inputs.) + +name: Publish + +on: + push: + tags: ['v*'] + +permissions: {} + +jobs: + publish: + name: Build and publish to PyPI (Trusted Publishing) + runs-on: ubuntu-latest + environment: + name: publish-pypi + url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + permissions: + id-token: write # OIDC to PyPI + sigstore for attestations. + attestations: write # Write build-provenance + SBOM predicates. + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Build sdist and wheel + run: uv build + + - name: Generate CycloneDX SBOM + run: | + uv sync --frozen --no-dev + uv run --with cyclonedx-bom cyclonedx-py environment .venv \ + --output-format JSON \ + --output-file sbom.cdx.json + + - name: Attest build provenance (SLSA) + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: 'dist/*' + + - name: Attest SBOM (CycloneDX) + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: 'dist/*' + sbom-path: sbom.cdx.json + + - name: Upload SBOM artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sbom + path: sbom.cdx.json + if-no-files-found: error + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/validate-pr-title.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/validate-pr-title.yaml.template new file mode 100644 index 0000000..ec634f0 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/validate-pr-title.yaml.template @@ -0,0 +1,21 @@ +--- +name: "Validate PR Title" +# Ensure that the PR title conforms to the Conventional Commits and our choice of types and scopes, so that library version bumps can be detected automatically + +on: + pull_request: + types: [opened, edited, synchronize] + +permissions: {} + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + - run: python3 .github/check-conventional-pr-title.py + env: + PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/__init__.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md.py new file mode 100755 index 0000000..13ccd07 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md.py @@ -0,0 +1,77 @@ +"""Check: AGENTS.md present (best-of-class; agent-onboarding entry point). +Tier coverage: product, canonical. Personal-tier: informational only. + +Convention: keep it minimal — a short pointer file, not an encyclopaedia. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'agents-md' +APPLIES = 'product,canonical,personal' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + p = Path('AGENTS.md') + if p.is_file(): + lines = p.read_text().count('\n') + if lines > 200: + emit_check( + CHECK_ID, + 'fail', + f"AGENTS.md present but at {lines} lines is well past the 'keep it minimal' " + f'convention.', + {'path': 'AGENTS.md', 'lines': lines}, + { + 'kind': 'judgement', + 'human_review': ( + 'Trim AGENTS.md down — point at HACKING/CONTRIBUTING for depth; keep ' + 'AGENTS.md to setup commands and conventions only.' + ), + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'pass', + f'AGENTS.md present ({lines} lines).', + {'path': 'AGENTS.md', 'lines': lines}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'No AGENTS.md found.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-agents-md.py', + 'human_review': 'Customise the dev-setup commands for this repo (uv / go / make / ' + 'just).', + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_battery.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_battery.py new file mode 100755 index 0000000..4ef2e44 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_battery.py @@ -0,0 +1,333 @@ +"""Check: this repo's AGENTS.md question battery still describes the repo. +Tier coverage: product, canonical, personal. + +A question battery (assets/question-batteries/.yaml) records, for each +AGENTS.md line that earns its place, the question an agent would be asked, the +checkable answer, the source line it derives from, and the override/cache +classification. It makes Layer 2 behavioural re-tests mechanical to run when +Layer 1 or Layer 3 triggers them. Schema and rationale: +references/question-batteries.md. + +This check validates the battery against the repo: + +1. Schema — every entry has the required fields with known enum values. +2. Source lines — every `source_line` still appears in AGENTS.md (whitespace + collapsed on both sides, so a line that wraps in the file still matches). +3. Assertions — every `verify` assertion still holds: paths resolve, patterns + match, named gocheck suites still live in the named package. + +Assertions are static by design. Layer 1's agents-md-content check already +classifies and executes the commands; running them here too would double the +runtime and the environment surface for no new signal. + +Batteries exist only for repos that have been through the Layer 2 authoring +gate. A repo with no battery is `na`, not a gap. + +Convention: one script emits exactly one JSON result (see lib/common.py). +""" + +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +import yaml + +from ..common import ( + ASSETS, + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + origin_url, + parse_tier, + tier_applies, +) + +CHECK_ID = 'agents-md-battery' +APPLIES = 'product,canonical,personal' + +BATTERIES_DIR = ASSETS / 'question-batteries' + +CLASSIFICATIONS = {'override', 'cache'} +GRADES = {'command', 'keywords', 'judgement'} +VERIFY_KINDS = {'path_exists', 'text_in_file', 'suite_in_package', 'none'} +REQUIRED_ENTRY_KEYS = { + 'id', + 'question', + 'classification', + 'source_line', + 'answer', + 'verify', + 'ci_verifiable', +} + + +def parse_flag(name: str) -> str: + prefix = f'--{name}=' + for arg in sys.argv[1:]: + if arg.startswith(prefix): + return arg[len(prefix) :] + return '' + + +def battery_path() -> Path | None: + """Explicit --battery= wins; otherwise the battery named after the + repo the origin URL points at.""" + explicit = parse_flag('battery') + if explicit: + p = Path(explicit) + return p if p.is_file() else None + url = origin_url() + if not url: + return None + name = url.rstrip('/').split('/')[-1] + candidate = BATTERIES_DIR / f'{name}.yaml' + return candidate if candidate.is_file() else None + + +def collapse(text: str) -> str: + return ' '.join(text.split()) + + +def validate_schema(entry: dict, index: int) -> list[str]: + where = entry.get('id') or f'entry[{index}]' + problems = [] + for key in sorted(REQUIRED_ENTRY_KEYS - set(entry)): + problems.append(f"{where}: missing required key '{key}'") + if entry.get('classification') not in CLASSIFICATIONS and 'classification' in entry: + problems.append(f'{where}: unknown classification {entry["classification"]!r}') + + answer = entry.get('answer') + if isinstance(answer, dict): + grade = answer.get('grade') + if grade not in GRADES: + problems.append(f'{where}: unknown answer.grade {grade!r}') + elif grade == 'command' and not answer.get('expect'): + problems.append(f"{where}: answer.grade 'command' needs 'expect'") + elif grade == 'keywords' and not answer.get('require'): + problems.append(f"{where}: answer.grade 'keywords' needs 'require'") + elif grade == 'judgement' and not answer.get('rubric'): + problems.append(f"{where}: answer.grade 'judgement' needs 'rubric'") + elif 'answer' in entry: + problems.append(f"{where}: 'answer' must be a mapping") + + verify = entry.get('verify') + if isinstance(verify, list) and verify: + for assertion in verify: + if not isinstance(assertion, dict): + problems.append(f'{where}: each verify assertion must be a mapping') + continue + kind = assertion.get('kind') + if kind not in VERIFY_KINDS: + problems.append(f'{where}: unknown verify kind {kind!r}') + elif kind == 'none' and not assertion.get('reason'): + problems.append(f"{where}: verify kind 'none' needs 'reason'") + elif 'verify' in entry: + problems.append(f"{where}: 'verify' must be a non-empty list") + + if entry.get('ci_verifiable') is False and not entry.get('gated_by'): + problems.append(f"{where}: ci_verifiable false needs 'gated_by'") + return problems + + +def run_assertion(assertion: dict, entry_id: str, root: Path) -> dict | None: + """Return a finding dict when the assertion fails, else None.""" + kind = assertion['kind'] + if kind == 'none': + return None + + if kind == 'path_exists': + rel = assertion.get('path', '') + if not (root / rel).exists(): + return {'entry': entry_id, 'kind': kind, 'path': rel, 'problem': 'path does not exist'} + return None + + if kind == 'text_in_file': + rel = assertion.get('file', '') + pattern = assertion.get('pattern', '') + target = root / rel + if not target.is_file(): + return {'entry': entry_id, 'kind': kind, 'file': rel, 'problem': 'file does not exist'} + try: + compiled = re.compile(pattern, re.MULTILINE) + except re.error as exc: + return { + 'entry': entry_id, + 'kind': kind, + 'file': rel, + 'pattern': pattern, + 'problem': f'invalid pattern: {exc}', + } + if not compiled.search(target.read_text(errors='replace')): + return { + 'entry': entry_id, + 'kind': kind, + 'file': rel, + 'pattern': pattern, + 'problem': 'pattern not found in file', + } + return None + + # suite_in_package + suite = assertion.get('suite', '') + package = assertion.get('package', '') + pkg_dir = root / package + if not pkg_dir.is_dir(): + return { + 'entry': entry_id, + 'kind': kind, + 'suite': suite, + 'package': package, + 'problem': 'package directory does not exist', + } + needle = re.compile(rf'\b{re.escape(suite)}\b') + for go_file in pkg_dir.rglob('*.go'): + try: + if needle.search(go_file.read_text(errors='replace')): + return None + except OSError: + continue + return { + 'entry': entry_id, + 'kind': kind, + 'suite': suite, + 'package': package, + 'problem': 'suite identifier not found anywhere in package', + } + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + root = cd_repo_root() + + path = battery_path() + if path is None: + emit_check( + CHECK_ID, + 'na', + 'No question battery for this repo — it has not been through the ' + 'Layer 2 authoring gate (see references/question-batteries.md).', + ) + return EXIT_NA + + try: + battery = yaml.safe_load(path.read_text()) or {} + except yaml.YAMLError as exc: + emit_check(CHECK_ID, 'fail', f'Battery {path.name} is not valid YAML: {exc}') + return EXIT_FAIL + + entries = battery.get('entries') or [] + agents_md = root / 'AGENTS.md' + if not agents_md.is_file(): + emit_check( + CHECK_ID, + 'fail', + f'Battery {path.name} describes {len(entries)} AGENTS.md line(s), ' + 'but the repo has no AGENTS.md.', + {'battery': path.name, 'entries_total': len(entries)}, + {'kind': 'judgement', 'human_review': 'Restore AGENTS.md or retire the battery.'}, + ) + return EXIT_FAIL + + md_text = agents_md.read_text(errors='replace') + md_collapsed = collapse(md_text) + + schema_findings: list[str] = [] + drifted: list[dict] = [] + verify_findings: list[dict] = [] + by_class: dict[str, int] = {} + by_grade: dict[str, int] = {} + unanchored: list[str] = [] + not_ci_verifiable: list[dict] = [] + + for i, entry in enumerate(entries): + schema_findings.extend(validate_schema(entry, i)) + entry_id = entry.get('id') or f'entry[{i}]' + + by_class[entry.get('classification', 'unknown')] = ( + by_class.get(entry.get('classification', 'unknown'), 0) + 1 + ) + grade = (entry.get('answer') or {}).get('grade', 'unknown') + by_grade[grade] = by_grade.get(grade, 0) + 1 + + source_line = entry.get('source_line', '') + if source_line and collapse(source_line) not in md_collapsed: + drifted.append({'entry': entry_id, 'source_line': source_line}) + + for assertion in entry.get('verify') or []: + if not isinstance(assertion, dict) or assertion.get('kind') not in VERIFY_KINDS: + continue + if assertion['kind'] == 'none': + unanchored.append(entry_id) + continue + finding = run_assertion(assertion, entry_id, root) + if finding: + verify_findings.append(finding) + + if entry.get('ci_verifiable') is False: + not_ci_verifiable.append({'entry': entry_id, 'gated_by': entry.get('gated_by', '')}) + + seeded_digest = (battery.get('source') or {}).get('agents_md_sha256', '') + current_digest = hashlib.sha256(md_text.encode()).hexdigest() + + evidence = { + 'battery': path.name, + 'entries_total': len(entries), + 'entries_by_classification': by_class, + 'entries_by_answer_grade': by_grade, + 'drifted_source_lines': drifted, + 'verify_findings': verify_findings, + 'schema_findings': schema_findings, + 'unanchored_entries': unanchored, + 'not_ci_verifiable': not_ci_verifiable, + # Non-failing re-test trigger, not a defect: the file may have improved. + 'agents_md_changed_since_seeding': bool(seeded_digest) and seeded_digest != current_digest, + } + + problems = [] + if schema_findings: + problems.append(f'{len(schema_findings)} schema finding(s)') + if drifted: + problems.append(f'{len(drifted)} source line(s) no longer in AGENTS.md') + if verify_findings: + problems.append(f'{len(verify_findings)} verify assertion(s) failed') + + if problems: + emit_check( + CHECK_ID, + 'fail', + f'Question battery {path.name}: ' + '; '.join(problems) + '.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'A drifted source line or failed assertion means the repo ' + 'moved under the battery. Re-run the Layer 2 gate for the ' + 'affected entries, then update AGENTS.md and the battery ' + 'together.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'pass', + f'Question battery {path.name} matches AGENTS.md: {len(entries)} ' + f'entr(ies) anchored, {len(unanchored)} with no repo anchor by design, ' + f'{len(not_ci_verifiable)} not confirmable by an automated run.', + evidence, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_content.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_content.py new file mode 100755 index 0000000..62582f8 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/agents_md_content.py @@ -0,0 +1,448 @@ +"""Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). +Tier coverage: product, canonical, personal. + +Implements the five Layer 1 checks from +roadmap/26.10/repo-setup/agents-md-validation.md (canonical-work-queue): + +1. Commands parse and their entry-point tool resolves in a dev environment. +2. Safe commands actually pass: runnable (lint/format-check/unit-test/build) + commands are executed and must exit 0; environment-gated commands + (integration needing Docker/LXD/juju, root-only tests, anything that would + mutate the tree or start a long-running process) are only parsed and + reported verify-manually, with the gating dependency named. +3. Paths and symbols resolve: every referenced file exists; every named + gocheck test suite (`-check.f ` after a `go test `) still + lives in the named package; every "`Symbol` in `path`" reference resolves. +4. Version pins mentioned in prose (`tool@vX.Y.Z`) match what + .github/workflows actually pin. +5. Scope lint: flag harness-shaped content (attribution trailers, tool + hints, per-agent config) that belongs in harness config, not AGENTS.md. + +This is a content check, not a presence check — see agents-md.py for +presence/length. If AGENTS.md is absent this check is n/a. + +Convention: one script emits exactly one JSON result (see lib/common.py); +all five sub-checks are folded into a single pass/fail with per-sub-check +evidence, following check.py's one-line-of-JSON-per-script contract. +""" + +from __future__ import annotations + +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + run, + tier_applies, +) + +CHECK_ID = 'agents-md-content' +APPLIES = 'product,canonical,personal' + +RUNNABLE_TIMEOUT_SECONDS = 180 + +FENCE_RE = re.compile(r'```[ \t]*([A-Za-z0-9_+-]*)\n(.*?)\n?```', re.DOTALL) +FENCE_LANGS = {'', 'bash', 'sh', 'shell', 'console', 'zsh'} +TABLE_ROW_RE = re.compile(r'^\|(.+)\|[ \t]*$') +INLINE_CODE_RE = re.compile(r'`([^`\n]+)`') +COMMAND_ENTRYPOINTS = { + 'go', + 'tox', + 'make', + 'uv', + 'pytest', + 'docker', + 'npm', + 'cargo', + 'python', + 'python3', + 'pip', + 'sudo', + 'gofmt', + 'staticcheck', + 'govulncheck', + 'ruff', + 'black', + 'flake8', + 'pyright', + 'ty', + 'mypy', + 'just', +} +ENV_ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=') + +# Check 2 classification. Order matters: side-effecting first, then +# environment-gate keywords, then the runnable allowlist. Anything matching +# none of these is treated conservatively as environment-gated ("not +# recognised as safe" rather than risk running an unknown command). +SIDE_EFFECT_RE = re.compile( + r'\bgo run\b|\bgo install\b|\bgo fmt\b(?!.*-l)|\bpip install\b' + r'|\buv tool install\b|\buv pip install\b|(?:tox -e|make)\s+format\b' + r'|\bruff format\b(?!.*(--check|--diff))|\bblack\b(?!.*--check)' + r'|\bmake\s+run\b|\bmake\s+cli-help\b|\bnpm install\b' +) +ENV_GATE_PATTERNS = [ + (re.compile(r'\bdocker\b|\bcompose\b', re.IGNORECASE), 'Docker'), + (re.compile(r'\blxd\b', re.IGNORECASE), 'LXD'), + (re.compile(r'\bjuju\b', re.IGNORECASE), 'juju'), + (re.compile(r'\bcharmcraft\b', re.IGNORECASE), 'charmcraft'), + (re.compile(r'\bsudo\b|\broot\b', re.IGNORECASE), 'root/sudo'), + (re.compile(r'\bintegration\b', re.IGNORECASE), 'integration environment'), + (re.compile(r'CHARM_PATH|packed charms?', re.IGNORECASE), 'packed charms'), +] +RUNNABLE_HINT_RE = re.compile( + r'\btest\b|\bunit\b|\bpytest\b|\blint\b|\bbuild\b|\bvet\b|--check\b|--diff\b' + r'|staticcheck|govulncheck|pyright|\bty check\b|gofmt -l', + re.IGNORECASE, +) + +VERSION_PIN_RE = re.compile(r'([A-Za-z0-9_.\-/]+)@v(\d+\.\d+(?:\.\d+)?)') +SUITE_RE = re.compile(r'go test\s+(\.[^\s]+)\s+.*-check\.f[= ]([A-Za-z_][A-Za-z0-9_]*)') +SYMBOL_IN_PATH_RE = re.compile(r'`([A-Za-z_][\w.]*)`\s+in\s+`([^`]+)`') +MD_LINK_RE = re.compile(r'\[[^\]]*\]\(([^)\s]+)\)') +FILE_EXT_RE = re.compile(r'\.(md|py|go|toml|yaml|yml|txt|cfg|ini|sh|json|lock)$', re.IGNORECASE) +KNOWN_EXTENSIONLESS_FILENAMES = {'dockerfile', 'makefile', 'license', 'copying'} + +SCOPE_LINT_PATTERNS = [ + (re.compile(r'Co-Authored-By', re.IGNORECASE), 'attribution trailer (Co-Authored-By)'), + (re.compile(r'Generated (with|by)\s*\[?Claude', re.IGNORECASE), 'Claude attribution line'), + (re.compile(r'Claude Code', re.IGNORECASE), 'harness name (Claude Code)'), + (re.compile(r'claude\.ai/code', re.IGNORECASE), 'harness URL (claude.ai/code)'), + (re.compile(r'GitHub Copilot|\bCopilot\b'), 'harness name (Copilot)'), + (re.compile(r'\bChatGPT\b|\bOpenAI\b'), 'harness name (ChatGPT/OpenAI)'), + (re.compile(r'\bAnthropic\b'), 'harness vendor name (Anthropic)'), + (re.compile(r'\U0001F916'), 'robot-emoji attribution marker'), + (re.compile(r'\.claude/'), 'harness-specific config path (.claude/)'), +] + + +def extract_commands(text: str) -> list[tuple[str, str]]: + """Return (raw_command, source) pairs from fenced shell blocks and + markdown table cells that look like commands.""" + commands: list[tuple[str, str]] = [] + for m in FENCE_RE.finditer(text): + lang = m.group(1).lower() + if lang not in FENCE_LANGS: + continue + for line in m.group(2).splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + cmd = re.split(r'\s+#\s?', line, maxsplit=1)[0].strip() + if cmd: + commands.append((cmd, 'fenced')) + for line in text.splitlines(): + row = TABLE_ROW_RE.match(line.strip()) + if not row: + continue + for cell in row.group(1).split('|'): + cell = cell.strip() + code_m = INLINE_CODE_RE.fullmatch(cell) + if not code_m: + continue + candidate = code_m.group(1).strip() + first_tok = candidate.split()[0] if candidate.split() else '' + if first_tok in COMMAND_ENTRYPOINTS: + commands.append((candidate, 'table')) + return commands + + +def entry_point_tool(cmd: str) -> str: + """First non-assignment token of the whole command line. A tool + introduced mid-line by an explicit install step (e.g. `go install X && + X ...`) is intentionally not checked here — it's expected to be absent + until that install step runs.""" + try: + tokens = shlex.split(cmd) + except ValueError: + return '' + for tok in tokens: + if ENV_ASSIGN_RE.match(tok): + continue + return tok + return '' + + +def classify_command(cmd: str) -> tuple[str, str]: + """Return (bucket, reason). bucket is 'runnable' or 'environment-gated'.""" + if SIDE_EFFECT_RE.search(cmd): + return ( + 'environment-gated', + ( + 'would mutate the working tree or start a long-running process — not executed ' + 'automatically' + ), + ) + for pattern, name in ENV_GATE_PATTERNS: + if pattern.search(cmd): + return 'environment-gated', name + if RUNNABLE_HINT_RE.search(cmd): + return 'runnable', '' + return 'environment-gated', 'not recognised as a safe check command — verify manually' + + +def looks_like_path(cand: str) -> bool: + if not cand or ' ' in cand or cand.startswith(('http://', 'https://')): + return False + if '/' in cand: + # Exclude Go/domain-style import paths (github.com/..., gopkg.in/..., + # golang.org/..., honnef.co/...) — a dotted first segment that isn't + # itself a relative-path marker ("." / "..") means "module path", + # not "local file". + first_seg = cand.split('/', 1)[0] + if '.' in first_seg and not first_seg.startswith('.'): + return False + return True + if cand.startswith('.'): + return True + if FILE_EXT_RE.search(cand): + return True + return cand.lower() in KNOWN_EXTENSIONLESS_FILENAMES + + +def extract_referenced_paths(text: str) -> set[str]: + paths: set[str] = set() + for m in MD_LINK_RE.finditer(text): + target = m.group(1) + if target.startswith(('http://', 'https://', '#', 'mailto:')): + continue + paths.add(target) + for m in INLINE_CODE_RE.finditer(text): + cand = m.group(1).strip() + if looks_like_path(cand): + paths.add(cand) + return paths + + +def workflow_texts(root: Path) -> dict[str, str]: + wf_dir = root / '.github' / 'workflows' + out: dict[str, str] = {} + if not wf_dir.is_dir(): + return out + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + out[str(p.relative_to(root))] = p.read_text(errors='replace') + except OSError: + continue + return out + + +def check_version_drift( + pins: list[tuple[str, str]], workflows: dict[str, str] +) -> tuple[list[dict], list[dict]]: + """Return (drifted, checked). checked includes every pin that could be + cross-referenced against a workflow (pass or fail), for evidence + transparency — e.g. a tool version pinned differently in an unrelated + workflow is visible even when the AGENTS.md claim matches somewhere.""" + drifted: list[dict] = [] + checked: list[dict] = [] + for tool, doc_version in pins: + found: set[str] = set() + for text in workflows.values(): + for vm in re.finditer(re.escape(tool) + r'@v(\d+\.\d+(?:\.\d+)?)', text): + found.add(f'v{vm.group(1)}') + if not found: + continue + entry = {'tool': tool, 'doc_version': doc_version, 'ci_versions': sorted(found)} + checked.append(entry) + if doc_version not in found: + drifted.append(entry) + return drifted, checked + + +def scope_lint(text: str) -> list[str]: + return [label for pattern, label in SCOPE_LINT_PATTERNS if pattern.search(text)] + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + root = cd_repo_root() + + p = Path('AGENTS.md') + if not p.is_file(): + emit_check( + CHECK_ID, + 'na', + 'No AGENTS.md to content-check (see agents-md check for presence).', + ) + return EXIT_NA + + text = p.read_text(errors='replace') + + # --- Checks 1 & 2: commands --- + commands = extract_commands(text) + missing_tools: list[dict] = [] + seen_missing_tools: set[str] = set() + runnable_results: list[dict] = [] + gated: list[dict] = [] + + for cmd, _source in commands: + tool = entry_point_tool(cmd) + if tool and tool not in seen_missing_tools and shutil.which(tool) is None: + seen_missing_tools.add(tool) + missing_tools.append({'command': cmd, 'tool': tool}) + + bucket, reason = classify_command(cmd) + if bucket == 'environment-gated': + gated.append({'command': cmd, 'gating_dependency': reason}) + continue + + try: + tokens = shlex.split(cmd) + except ValueError: + runnable_results.append({'command': cmd, 'status': 'unparseable'}) + continue + try: + proc = run(tokens, cwd=root, timeout=RUNNABLE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + runnable_results.append({'command': cmd, 'status': 'timeout'}) + continue + except OSError as exc: + runnable_results.append({'command': cmd, 'status': 'error', 'detail': str(exc)}) + continue + runnable_results.append({ + 'command': cmd, + 'status': 'pass' if proc.returncode == 0 else 'fail', + 'returncode': proc.returncode, + 'stderr_tail': proc.stderr[-500:] if proc.returncode != 0 else '', + }) + + failed_runnable = [r for r in runnable_results if r['status'] != 'pass'] + + # --- Check 3: paths & symbols --- + ref_paths = extract_referenced_paths(text) + missing_paths = sorted(rp for rp in ref_paths if not (root / rp).exists()) + + symbol_findings: list[dict] = [] + for sym, sym_path in SYMBOL_IN_PATH_RE.findall(text): + target = root / sym_path + if not target.is_file(): + symbol_findings.append({ + 'symbol': sym, + 'path': sym_path, + 'problem': 'path does not exist', + }) + continue + body = target.read_text(errors='replace') + if not re.search(rf'\b{re.escape(sym)}\b', body): + symbol_findings.append({ + 'symbol': sym, + 'path': sym_path, + 'problem': 'symbol not found in file', + }) + + suite_findings: list[dict] = [] + for pkg, suite in SUITE_RE.findall(text): + pkg_dir = (root / pkg).resolve() + if not pkg_dir.is_dir(): + suite_findings.append({ + 'suite': suite, + 'package': pkg, + 'problem': 'package directory does not exist', + }) + continue + found = False + for go_file in pkg_dir.rglob('*.go'): + try: + if re.search(rf'\b{re.escape(suite)}\b', go_file.read_text(errors='replace')): + found = True + break + except OSError: + continue + if not found: + suite_findings.append({ + 'suite': suite, + 'package': pkg, + 'problem': 'suite identifier not found anywhere in package', + }) + + # --- Check 4: version pins vs CI --- + pins = [ + (path.rstrip('/').split('/')[-1], f'v{ver}') for path, ver in VERSION_PIN_RE.findall(text) + ] + workflows = workflow_texts(root) + version_drift, version_checked = check_version_drift(pins, workflows) + + # --- Check 5: scope lint --- + scope_findings = scope_lint(text) + + problems: list[str] = [] + if missing_tools: + problems.append(f'{len(missing_tools)} command tool(s) not resolvable') + if failed_runnable: + problems.append(f'{len(failed_runnable)} runnable command(s) did not pass') + if missing_paths: + problems.append(f'{len(missing_paths)} referenced path(s) missing') + if symbol_findings: + problems.append(f'{len(symbol_findings)} referenced symbol(s) unresolved') + if suite_findings: + problems.append(f'{len(suite_findings)} named test suite(s) not found in package') + if version_drift: + problems.append(f'{len(version_drift)} version pin(s) drifted from CI') + if scope_findings: + problems.append(f'{len(scope_findings)} scope-lint finding(s) (harness-shaped content)') + + evidence = { + 'path': 'AGENTS.md', + 'commands_extracted': len(commands), + 'missing_tools': missing_tools, + 'runnable_checked': len(runnable_results), + 'runnable_failed': failed_runnable, + 'environment_gated': gated, + 'paths_checked': sorted(ref_paths), + 'missing_paths': missing_paths, + 'symbol_findings': symbol_findings, + 'suite_findings': suite_findings, + 'version_pins_checked': version_checked, + 'version_drift': version_drift, + 'scope_lint_findings': scope_findings, + } + + if problems: + emit_check( + CHECK_ID, + 'fail', + 'AGENTS.md content check: ' + '; '.join(problems) + '.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Review the evidence fields for the failing sub-check(s) ' + '(missing_tools / runnable_failed / missing_paths / ' + 'symbol_findings / suite_findings / version_drift / ' + 'scope_lint_findings) and update AGENTS.md or the ' + 'underlying repo to match.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'pass', + f'AGENTS.md content verified: {len(commands)} command(s) parsed ' + f'({len(runnable_results)} run, {len(gated)} environment-gated/' + f'verify-manually), {len(ref_paths)} path(s) resolved, ' + f'{len(version_checked)} version pin(s) cross-checked against CI, ' + 'no scope-lint findings.', + evidence, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py new file mode 100755 index 0000000..dc63de7 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py @@ -0,0 +1,255 @@ +"""Check: actions/attest-build-provenance present in release / publish workflows, +with a `subject-path:` input, AND ordered to run *before* the publish step. +Tier coverage: product, canonical. + +Ordering rules: + - Same job: attest step index must be < publish step index. + - Cross-job: attest job must transitively appear in the publish job's + `needs:` chain (e.g. build-and-attest -> publish via uploaded artefacts). + +Uses python3 + PyYAML for parsing. Falls back to `unknown` if either is +unavailable, rather than emitting a brittle grep-based verdict. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + EXIT_UNKNOWN, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'attest-build-provenance' +APPLIES = 'product,canonical' + +ATTEST = 'actions/attest-build-provenance' +PUBLISH_ACTIONS = ( + 'pypa/gh-action-pypi-publish', + 'snapcore/action-publish', + 'goreleaser/goreleaser-action', + 'softprops/action-gh-release', +) +TEST_PYPI_HOSTS = ('test.pypi.org', 'testpypi.org') + + +def _targets_test_pypi(step: dict) -> bool: + with_block = step.get('with') or {} + url = with_block.get('repository-url') or with_block.get('repository_url') or '' + if any(h in url for h in TEST_PYPI_HOSTS): + return True + run_str = step.get('run') or '' + if any(h in run_str for h in TEST_PYPI_HOSTS): + return True + env = step.get('env') or {} + url2 = env.get('TWINE_REPOSITORY_URL') or env.get('TWINE_REPOSITORY') or '' + return any(h in url2 for h in TEST_PYPI_HOSTS) + + +def is_publish_step(step) -> bool: + if not isinstance(step, dict): + return False + uses = step.get('uses') or '' + run_str = step.get('run') or '' + is_action_publish = any(p in uses for p in PUBLISH_ACTIONS) + is_twine_publish = 'twine upload' in run_str + if not (is_action_publish or is_twine_publish): + return False + if _targets_test_pypi(step): + return False + return True + + +def is_attest_step(step) -> bool: + if not isinstance(step, dict): + return False + uses = step.get('uses') or '' + return ATTEST in uses + + +def needs_of(job) -> list[str]: + needs = job.get('needs') if isinstance(job, dict) else None + if needs is None: + return [] + if isinstance(needs, str): + return [needs] + return list(needs) + + +def transitive_needs(jobs: dict, start: str) -> set[str]: + seen: set[str] = set() + stack = [start] + while stack: + n = stack.pop() + if n in seen or n not in jobs: + continue + seen.add(n) + stack.extend(needs_of(jobs[n])) + seen.discard(start) + return seen + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + if not Path('.github/workflows').is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/workflows directory; nothing to attest.') + return EXIT_NA + + try: + import yaml # type: ignore + except Exception: + emit_check( + CHECK_ID, + 'unknown', + "PyYAML not installed (python3 -c 'import yaml' fails); cannot parse workflow YAML.", + ) + return EXIT_UNKNOWN + + failures: list[str] = [] + publish_workflows: list[str] = [] + attested_workflows: list[str] = [] + + wf_dir = Path('.github/workflows') + workflow_paths = sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))) + + for path in workflow_paths: + try: + with open(path) as f: + doc = yaml.safe_load(f) + except Exception as e: + failures.append(f'{path}: YAML parse error ({e})') + continue + if not isinstance(doc, dict): + continue + jobs = doc.get('jobs') or {} + if not isinstance(jobs, dict): + continue + + job_steps: dict[str, dict] = {} + for jname, job in jobs.items(): + if not isinstance(job, dict): + continue + steps = job.get('steps') or [] + rec = {'attests': [], 'publishes': []} + for i, step in enumerate(steps): + if not isinstance(step, dict): + continue + if is_attest_step(step): + with_block = step.get('with') or {} + subj = ( + with_block.get('subject-path') + or with_block.get('subject-checksums') + or with_block.get('subject-digest') + ) + rec['attests'].append({'index': i, 'has_subject': bool(subj)}) + if is_publish_step(step): + rec['publishes'].append({'index': i}) + job_steps[jname] = rec + + publish_jobs = [j for j, r in job_steps.items() if r['publishes']] + if not publish_jobs: + continue + publish_workflows.append(str(path)) + + workflow_ok = True + for pjob in publish_jobs: + prec = job_steps[pjob] + first_publish_idx = min(p['index'] for p in prec['publishes']) + + same_job_attests = [a for a in prec['attests'] if a['index'] < first_publish_idx] + same_job_attest_no_order = [ + a for a in prec['attests'] if a['index'] >= first_publish_idx + ] + if same_job_attests: + if not any(a['has_subject'] for a in same_job_attests): + failures.append( + f'{path}:{pjob}: attest step has no ' + f'subject-path/subject-digest/subject-checksums' + ) + workflow_ok = False + continue + if same_job_attest_no_order: + failures.append( + f'{path}:{pjob}: attest step runs AFTER the publish step (must be before)' + ) + workflow_ok = False + continue + + upstream = transitive_needs(jobs, pjob) + upstream_attests: list[tuple[str, dict]] = [] + for uj in upstream: + for a in job_steps.get(uj, {}).get('attests', []): + upstream_attests.append((uj, a)) + if not upstream_attests: + failures.append( + f'{path}:{pjob}: publish step has no attest step in this job or in any ' + f'upstream `needs:` job' + ) + workflow_ok = False + continue + if not any(a['has_subject'] for _, a in upstream_attests): + ujs = ','.join(sorted({uj for uj, _ in upstream_attests})) + failures.append( + f'{path}:{pjob}: upstream attest step(s) in {ujs} have no subject-path' + ) + workflow_ok = False + continue + if workflow_ok: + attested_workflows.append(str(path)) + + evidence = { + 'publish_workflows': publish_workflows, + 'attested_workflows': attested_workflows, + 'failures': failures, + } + + if not publish_workflows: + emit_check( + CHECK_ID, 'na', 'No publish/release workflow found; attestation not applicable.' + ) + return EXIT_NA + + if not failures: + emit_check( + CHECK_ID, + 'pass', + 'Build provenance attestation present, with subject-path, ordered before publish.', + evidence, + ) + return EXIT_PASS + + top = failures[0] if len(failures) == 1 else f'{failures[0]} (and {len(failures) - 1} more)' + reason_q = top.replace('"', '\\"') + emit_check( + CHECK_ID, + 'fail', + f'Publish workflow(s) missing or misordered attestation: {reason_q}', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Wire actions/attest-build-provenance@ BEFORE the publish step in the same ' + 'job, or in an upstream job in the publish jobs `needs:` chain. Set ' + '`with.subject-path` to the published artefact glob (e.g. dist/*). Concierge skip ' + 'applies until goreleaser build/publish split lands.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_sbom_deprecated.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_sbom_deprecated.py new file mode 100755 index 0000000..9000a05 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_sbom_deprecated.py @@ -0,0 +1,79 @@ +"""Check: no use of the deprecated actions/attest-sbom action. +Tier coverage: all. + +actions/attest-sbom is deprecated in favour of actions/attest. Since v4 it +already runs as a thin wrapper over actions/attest, and its inputs +(subject-path, sbom-path) are compatible with the new action — so this is +a straight action swap with no behaviour change. + +Ref: https://github.com/actions/attest-sbom (deprecation notice). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'attest-sbom-deprecated' +APPLIES = 'all' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/workflows/ directory.') + return EXIT_NA + + hits: list[str] = [] + for path in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if 'actions/attest-sbom' in path.read_text(errors='replace'): + hits.append(str(path)) + except OSError: + continue + + if not hits: + emit_check( + CHECK_ID, + 'pass', + 'No use of the deprecated actions/attest-sbom action.', + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'actions/attest-sbom is deprecated; swap to actions/attest (same inputs).', + {'workflows': hits}, + { + 'kind': 'judgement', + 'human_review': ( + 'Replace `uses: actions/attest-sbom@` with ' + '`uses: actions/attest@` in the listed workflows; ' + 'subject-path and sbom-path inputs are compatible. ' + 'Ref: https://github.com/actions/attest-sbom (deprecation notice).' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/code_of_conduct.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/code_of_conduct.py new file mode 100755 index 0000000..31085a8 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/code_of_conduct.py @@ -0,0 +1,84 @@ +"""Check: CODE_OF_CONDUCT.md present. +Tier coverage: product, canonical. (Best-practice for personal too; +emitted as a softer fail.) + +Decision: link-only form pointing at the Ubuntu Code of Conduct, not a +full Contributor Covenant template. See references/decisions.md. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'code-of-conduct' +APPLIES = 'product,canonical,personal' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + for path in ('CODE_OF_CONDUCT.md', 'docs/CODE_OF_CONDUCT.md', '.github/CODE_OF_CONDUCT.md'): + p = Path(path) + if p.is_file(): + text = p.read_text(errors='replace') + if re.search(r'ubuntu\.com/community/ethos/code-of-conduct', text, re.IGNORECASE): + emit_check( + CHECK_ID, + 'pass', + 'CODE_OF_CONDUCT.md present and links to the Ubuntu Code of Conduct.', + {'path': path}, + ) + return EXIT_PASS + emit_check( + CHECK_ID, + 'fail', + ( + 'CODE_OF_CONDUCT.md present but does not link to the Ubuntu Code of Conduct ' + '(cycle convention: link-only form).' + ), + {'path': path}, + { + 'kind': 'judgement', + 'human_review': ( + 'Replace with link-only form pointing at ' + 'https://ubuntu.com/community/ethos/code-of-conduct (Ubuntu CoC has its ' + 'own ' + 'reporting/enforcement path via Community Council).' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'fail', + 'No CODE_OF_CONDUCT.md found.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-code-of-conduct.py', + 'human_review': 'None — template is fixed link-only form.', + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/contributing.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/contributing.py new file mode 100755 index 0000000..a027020 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/contributing.py @@ -0,0 +1,105 @@ +"""Check: CONTRIBUTING.md (or equivalent) present AND documents the PR +workflow with a "Pull requests" anchor. +Tier coverage: product, canonical. + +Why the anchor matters: .github/check-conventional-pr-title.py (the +validate-pr-title workflow's helper) prints an error message ending in +"Read more: https://github.com///blob//CONTRIBUTING.md#pull-requests". +If the destination file has no `# Pull requests` heading the link +silently lands at the top of the document — the audited Charm Tech +pattern (10 of 14 repos) is to have the heading. + +Accepted variants for the file itself: CONTRIBUTING.md, HACKING.md, +docs/contributing.md, docs/how-to/contribute.md, .github/CONTRIBUTING.md +(pebble uses HACKING.md). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'contributing' +APPLIES = 'product,canonical' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + found = '' + for path in ( + 'CONTRIBUTING.md', + 'HACKING.md', + 'docs/contributing.md', + 'docs/how-to/contribute.md', + '.github/CONTRIBUTING.md', + ): + if Path(path).is_file(): + found = path + break + + if not found: + emit_check( + CHECK_ID, + 'fail', + 'No CONTRIBUTING.md / HACKING.md / docs/contributing found.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-contributing.py', + 'human_review': ( + 'Customise the dev-setup pointer / project description for the repo (Python / ' + 'Go / docs).' + ), + }, + ) + return EXIT_FAIL + + text = Path(found).read_text(errors='replace') + # ^#{1,3}\s+pull\s+requests?\s*$ (multiline, case-insensitive) + if re.search(r'^#{1,3}[ \t]+pull[ \t]+requests?[ \t]*$', text, re.IGNORECASE | re.MULTILINE): + emit_check( + CHECK_ID, + 'pass', + f'Contributing guidance present at {found} with a Pull requests section.', + {'path': found, 'pull_requests_heading': True}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + f"{found} present but has no 'Pull requests' heading — the validate-pr-title.py \"Read " + f'more" URL (#pull-requests) will not anchor.', + {'path': found, 'pull_requests_heading': False}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a `# Pull requests` (or `## Pull requests`) section listing the allowed ' + 'Conventional-Commits types (chore, ci, docs, feat, fix, perf, refactor, revert, ' + 'test) and the no-scopes rule. See assets/CONTRIBUTING.md.template for the ' + 'canonical shape; for pebble-style HACKING.md the anchor can live there instead.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/conventional_commits.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/conventional_commits.py new file mode 100755 index 0000000..4a5e26d --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/conventional_commits.py @@ -0,0 +1,94 @@ +"""Check: Conventional-commits PR-title enforcement workflow present. +Tier coverage: product, canonical. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'conventional-commits' +APPLIES = 'product,canonical' + +PATTERN = re.compile( + r'amannn/action-semantic-pull-request|conventional-commit|check-conventional-pr-title' +) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, + 'fail', + 'No .github/workflows directory.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-validate-pr-title.py', + 'human_review': ( + 'Installs operator-style validate-pr-title.yaml + ' + 'check-conventional-pr-title.py. Confirm CONTRIBUTING.md documents the ' + 'allowed ' + 'types.' + ), + }, + ) + return EXIT_FAIL + + hit = '' + for path in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if PATTERN.search(path.read_text(errors='replace')): + hit = str(path) + break + except OSError: + continue + + if hit: + emit_check( + CHECK_ID, + 'pass', + 'PR-title Conventional-Commits enforcement wired up.', + {'workflow': hit}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'No Conventional-Commits PR-title workflow found.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-validate-pr-title.py', + 'human_review': ( + 'Installs operator-style validate-pr-title.yaml + check-conventional-pr-title.py ' + '(source: canonical/operator). Confirm CONTRIBUTING.md documents the allowed type ' + 'list (chore/ci/docs/feat/fix/perf/refactor/revert/test) and disallows scopes.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependabot.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependabot.py new file mode 100755 index 0000000..7c18c1e --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependabot.py @@ -0,0 +1,263 @@ +"""Check: .github/dependabot.yml exists, declares package ecosystems, and each +ecosystem has a cooldown of at least 7 days (Charm Tech baseline — see +charmlibs#499). The cooldown delays raising a PR for a freshly published +release so a malicious upload caught and yanked inside the window never +reaches CI. + +Tier coverage: product, canonical. (Personal-tier sees this as best-practice +advisory rather than mandatory.) + +Mandate: SEC0025 (Vulnerability Discovery & Identification) — cross-cutting +requirement for every Canonical repo. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + EXIT_UNKNOWN, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'dependabot' +APPLIES = 'product,canonical,personal' +MIN_COOLDOWN_DAYS = 7 + +ECOSYSTEM_LINE_RE = re.compile(r'^[ \t]*-[ \t]+package-ecosystem:', re.MULTILINE) +PRECOMMIT_ECO_RE = re.compile( + r"""^[ \t]*-[ \t]+package-ecosystem:[ \t]*["']?pre-commit["']?[ \t]*$""", + re.MULTILINE, +) +PRECOMMIT_REV_RE = re.compile(r'^[ \t]*rev:[ \t]+', re.MULTILINE) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + found_path = '' + for path in ('.github/dependabot.yml', '.github/dependabot.yaml'): + if Path(path).is_file(): + found_path = path + break + + if not found_path: + if tier == 'personal': + emit_check( + CHECK_ID, + 'fail', + 'No Dependabot config (personal tier — recommended, not mandated).', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-dependabot.py', + 'human_review': 'Confirm the default ecosystem set matches the repo.', + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'fail', + 'No .github/dependabot.yml found (required cross-cutting per SEC0025).', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-dependabot.py', + 'human_review': ( + 'Confirm the default ecosystem set matches the repo (pip/uv, gomod, ' + 'github-actions, docker).' + ), + }, + ) + return EXIT_FAIL + + text = Path(found_path).read_text(errors='replace') + ecos = len(ECOSYSTEM_LINE_RE.findall(text)) + if ecos == 0: + emit_check( + CHECK_ID, + 'fail', + f'{found_path} exists but declares no package-ecosystem entries.', + {'path': found_path}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add package-ecosystem blocks for each language/runtime the repo uses ' + '(pip/uv, ' + 'gomod, github-actions, docker).' + ), + }, + ) + return EXIT_FAIL + + # Cross-check: if .pre-commit-config.yaml carries any rev: entries, + # dependabot must declare the pre-commit ecosystem to bump the SHAs. + # See references/decisions.md § "Remote pre-commit hooks — SHA-pin". + pc_config = '' + for path in ('.pre-commit-config.yaml', '.pre-commit-config.yml'): + if Path(path).is_file(): + pc_config = path + break + if pc_config: + try: + pc_text = Path(pc_config).read_text(errors='replace') + except OSError: + pc_text = '' + if PRECOMMIT_REV_RE.search(pc_text) and not PRECOMMIT_ECO_RE.search(text): + emit_check( + CHECK_ID, + 'fail', + f'{found_path} is missing a pre-commit package-ecosystem entry — required ' + f'because {pc_config} carries rev: entries whose SHAs need Dependabot bumps ' + f'(Charm Tech baseline).', + {'path': found_path, 'ecosystems': ecos, 'pre_commit_config': pc_config}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a pre-commit package-ecosystem block to dependabot.yaml (same ' + 'shape as ' + 'github-actions, cooldown ≥7 days). See assets/dependabot.yaml.template.' + ), + }, + ) + return EXIT_FAIL + + # Parse and validate cooldown. + try: + doc = yaml.safe_load(text) + except Exception: + # Fall through to presence check. + if not re.search(r'^[ \t]*cooldown:', text, re.MULTILINE): + emit_check( + CHECK_ID, + 'fail', + f'Dependabot configured with {ecos} ecosystem(s), but no cooldown: block found ' + f'and YAML parser errored — could not auto-validate.', + {'path': found_path, 'ecosystems': ecos, 'cooldown_validated': False}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a cooldown block to every package-ecosystem entry with ' + 'default-days/semver-*-days ≥7.' + ), + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'unknown', + ( + 'Dependabot present with cooldown block, but YAML parser errored — cooldown ' + 'values ' + 'not validated.' + ), + {'path': found_path, 'ecosystems': ecos, 'cooldown_validated': False}, + ) + return EXIT_UNKNOWN + + if not isinstance(doc, dict): + # Match parse-error behaviour. + if not re.search(r'^[ \t]*cooldown:', text, re.MULTILINE): + emit_check( + CHECK_ID, + 'fail', + f'Dependabot configured with {ecos} ecosystem(s), but no cooldown: block found ' + f'and YAML parser errored — could not auto-validate.', + {'path': found_path, 'ecosystems': ecos, 'cooldown_validated': False}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a cooldown block to every package-ecosystem entry with ' + 'default-days/semver-*-days ≥7.' + ), + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'unknown', + ( + 'Dependabot present with cooldown block, but YAML parser errored — cooldown ' + 'values ' + 'not validated.' + ), + {'path': found_path, 'ecosystems': ecos, 'cooldown_validated': False}, + ) + return EXIT_UNKNOWN + + updates = doc.get('updates') or [] + problems: list[str] = [] + for entry in updates: + if not isinstance(entry, dict): + continue + eco = entry.get('package-ecosystem', '?') + direc = entry.get('directory', entry.get('directories', '?')) + label = f'{eco}@{direc}' + cd = entry.get('cooldown') + if not isinstance(cd, dict): + problems.append(f'{label}: no cooldown block') + continue + keys = ('default-days', 'semver-major-days', 'semver-minor-days', 'semver-patch-days') + if 'default-days' not in cd and not any(k in cd for k in keys): + problems.append(f'{label}: cooldown block present but no *-days field set') + continue + for k in keys: + if k in cd: + try: + v = int(cd[k]) + except (TypeError, ValueError): + problems.append(f'{label}: cooldown.{k} not an integer ({cd[k]!r})') + continue + if v < MIN_COOLDOWN_DAYS: + problems.append(f'{label}: cooldown.{k}={v} < {MIN_COOLDOWN_DAYS}') + + if not problems: + emit_check( + CHECK_ID, + 'pass', + f'Dependabot configured with {ecos} ecosystem(s); cooldown ≥{MIN_COOLDOWN_DAYS} ' + f'days on every entry.', + {'path': found_path, 'ecosystems': ecos, 'cooldown_validated': True}, + ) + return EXIT_PASS + + evidence = { + 'path': found_path, + 'ecosystems': ecos, + 'cooldown_validated': True, + 'detail': {'problems': problems, 'ecosystems': len(updates)}, + } + emit_check( + CHECK_ID, + 'fail', + f'Dependabot present but cooldown below Charm Tech baseline (≥{MIN_COOLDOWN_DAYS} days) ' + f'on one or more ecosystems.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Set cooldown.default-days (and any per-semver-tier overrides) to at least 7 on ' + 'every package-ecosystem entry. See assets/dependabot.yaml.template.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependency_review.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependency_review.py new file mode 100755 index 0000000..d5f9be5 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/dependency_review.py @@ -0,0 +1,78 @@ +"""Check: actions/dependency-review-action workflow present on PRs. +Tier coverage: product, canonical. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'dependency-review' +APPLIES = 'product,canonical' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, + 'fail', + 'No .github/workflows/ directory.', + {}, + {'kind': 'judgement', 'human_review': 'Set up workflows; then add dependency-review.'}, + ) + return EXIT_FAIL + + hit = '' + for path in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if 'actions/dependency-review-action' in path.read_text(errors='replace'): + hit = str(path) + break + except OSError: + continue + + if hit: + emit_check( + CHECK_ID, + 'pass', + 'dependency-review-action wired up.', + {'workflow': hit}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'No actions/dependency-review-action workflow.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a dependency-review.yaml workflow on pull_request, ~10 lines. Reference: ' + 'canonical/operator#2587 (open as of 2026-06-27).' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/gha_sha_pinning.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/gha_sha_pinning.py new file mode 100755 index 0000000..d5b71f4 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/gha_sha_pinning.py @@ -0,0 +1,106 @@ +"""Check: every third-party GHA action is SHA-pinned. No exceptions — +`actions/*`, `github/*`, `pypa/*`, `canonical/*` all pin to a commit +SHA, same as any other third-party action (see references/decisions.md). + +Tier coverage: product, canonical, personal. + +Implementation: greps `uses:` lines under .github/workflows/. A ref is +SHA-pinned iff it matches a 40-char hex string. Local action refs +(`./...`) are skipped. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'gha-sha-pinning' +APPLIES = 'product,canonical,personal' + +USES_RE = re.compile(r'^[ \t]*-?[ \t]*uses:[ \t]+(.+)$') +SHA_RE = re.compile(r'^[0-9a-fA-F]{40}$') + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/workflows directory.') + return EXIT_NA + + violations = 0 + violators: list[str] = [] + total = 0 + + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + text = p.read_text(errors='replace') + except OSError: + continue + for line in text.splitlines(): + m = USES_RE.match(line) + if not m: + continue + ref = m.group(1).strip() + # take first whitespace-delimited token, strip quotes + ref = ref.split()[0] if ref else '' + if ref.startswith('"') and ref.endswith('"'): + ref = ref[1:-1] + if ref.startswith("'") and ref.endswith("'"): + ref = ref[1:-1] + if not ref: + continue + if ref.startswith('./') or ref.startswith('.'): + continue + total += 1 + after_at = ref.split('@', 1)[1] if '@' in ref else ref + if not SHA_RE.match(after_at): + violations += 1 + violators.append(ref) + + if violations == 0: + emit_check( + CHECK_ID, + 'pass', + 'All third-party GHA actions SHA-pinned (no exceptions).', + {'actions_inspected': total}, + ) + return EXIT_PASS + + trimmed = ','.join(violators) + emit_check( + CHECK_ID, + 'fail', + f'{violations} third-party action ref(s) not SHA-pinned.', + {'actions_inspected': total, 'non_pinned': trimmed}, + { + 'kind': 'judgement', + 'human_review': ( + 'Replace each non-pinned ref with the upstream commit SHA + a # vX.Y.Z comment. ' + 'No ' + 'allowlist exceptions — actions/, github/, pypa/, canonical/ all pin the same way.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/immutable_releases.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/immutable_releases.py new file mode 100755 index 0000000..4125a78 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/immutable_releases.py @@ -0,0 +1,109 @@ +"""Check: GitHub releases are immutable (latest release's `immutable: true`). +Tier coverage: product, canonical. + +Implementation: uses `gh api repos///releases` if `gh` is +available; falls back to a 'unknown' note if it isn't. + +Known blockers (do not flag as fail): + - pebble: snap build (pebble#856) + - concierge: goreleaser monolith (concierge#172 / #142) +Heuristically: if the repo origin is canonical/{pebble,concierge,charmlibs}, +emit a note explaining the blocker. +""" + +from __future__ import annotations + +import shutil +import sys + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + EXIT_UNKNOWN, + emit_check, + origin_url, + parse_tier, + run, + tier_applies, +) + +CHECK_ID = 'immutable-releases' +APPLIES = 'product,canonical' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + url = origin_url() + slug = url[len('https://github.com/') :] if url.startswith('https://github.com/') else url + + if slug == 'canonical/pebble': + emit_check( + CHECK_ID, + 'na', + 'pebble immutable-releases blocked on snap-build process update (pebble#856).', + ) + return EXIT_NA + if slug == 'canonical/concierge': + emit_check( + CHECK_ID, + 'na', + ( + 'concierge immutable-releases blocked on goreleaser build/publish split ' + '(concierge#172 / #142).' + ), + ) + return EXIT_NA + + if not shutil.which('gh'): + emit_check( + CHECK_ID, 'unknown', 'gh CLI not installed; cannot query release immutability flag.' + ) + return EXIT_UNKNOWN + + r = run(['gh', 'api', f'repos/{slug}/releases?per_page=1', '--jq', '.[0].immutable // empty']) + flag = r.stdout.strip() if r.returncode == 0 else '' + if not flag: + emit_check( + CHECK_ID, + 'na', + f'No releases on {slug} yet — toggle the setting before the first release.', + ) + return EXIT_NA + + if flag == 'true': + emit_check( + CHECK_ID, + 'pass', + 'Latest release is immutable.', + {'slug': slug}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + ( + 'Latest release is NOT immutable. Setting needs to be flipped, or an active blocker ' + 'tracked.' + ), + {'slug': slug}, + { + 'kind': 'judgement', + 'human_review': ( + 'Flip the per-repo Make-published-releases-immutable toggle in GitHub Settings. ' + 'If ' + 'blocked on tooling (goreleaser, snap-build), record the blocker upstream and ' + 'revisit when the upstream lands.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/openssf_scorecard.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/openssf_scorecard.py new file mode 100755 index 0000000..c1552e4 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/openssf_scorecard.py @@ -0,0 +1,113 @@ +"""Check: OpenSSF Scorecard workflow + README badge. +Tier coverage: product, canonical. (Operator pilots; rest gated behind +its adoption — see references/open-investigations.md.) + +Pass — workflow uses ossf/scorecard-action; README has the badge. +Partial — workflow OR badge present but not both — emitted as fail with + human_review noting which half is missing. +Fail — neither present. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'openssf-scorecard' +APPLIES = 'product,canonical' + +BADGE_RE = re.compile(r'securityscorecards\.dev/projects/github\.com|scorecard\.dev/projects') + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + workflow = '' + wf_dir = Path('.github/workflows') + if wf_dir.is_dir(): + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if 'ossf/scorecard-action' in p.read_text(errors='replace'): + workflow = str(p) + break + except OSError: + continue + + badge = '' + for readme in ('README.md', 'README.rst', 'README.txt', 'readme.md'): + p = Path(readme) + if not p.is_file(): + continue + try: + if BADGE_RE.search(p.read_text(errors='replace')): + badge = readme + break + except OSError: + continue + + if workflow and badge: + emit_check( + CHECK_ID, + 'pass', + f'OpenSSF Scorecard workflow ({workflow}) and README badge ({badge}) present.', + {'workflow': workflow, 'badge_in': badge}, + ) + return EXIT_PASS + + if not workflow and not badge: + emit_check( + CHECK_ID, + 'fail', + ( + 'No OpenSSF Scorecard workflow or badge. (Note: 26.10-cycle rollout is gated ' + 'behind ' + "operator's adoption — see references/open-investigations.md.)" + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Wait for operator adoption to settle and propagate its workflow + ' + 'branch-protection wiring; do not invent conventions ahead of it.' + ), + }, + ) + return EXIT_FAIL + + missing = 'workflow' + if not badge: + missing = 'README badge' + if not workflow: + missing = 'workflow' + emit_check( + CHECK_ID, + 'fail', + f'Partial OpenSSF Scorecard setup — {missing} missing.', + {'workflow': workflow, 'badge_in': badge}, + { + 'kind': 'judgement', + 'human_review': 'Add the missing half (workflow or badge) to match the operator-led ' + 'convention.', + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/pre_commit_config.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/pre_commit_config.py new file mode 100755 index 0000000..f66bb71 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/pre_commit_config.py @@ -0,0 +1,169 @@ +"""Check: .pre-commit-config.yaml present (informational). +Tier coverage: product, canonical, personal. + +Cycle convention (see references/decisions.md): tool versions live in +pyproject.toml [dependency-groups], not in the pre-commit config's +rev: fields. The hooks invoke tools via `language: system` against +the lockfile. A config that pins versions in rev: fields is flagged +as a soft fail because it duplicates the source of truth. + +Carve-out: hooks from pre-commit/pre-commit-hooks (end-of-file-fixer, +trailing-whitespace, check-yaml, check-added-large-files, …) are +generic file-hygiene checks with no Python-tool counterpart in +pyproject.toml dependency-groups. Pinning their rev: is the standard +way to use them and does not duplicate any other source of truth, so +they are exempted from the *tool-version* count. + +The carve-out still has to be **SHA-pinned**, not tag-pinned — same +discipline as gha-sha-pinning (see references/decisions.md § "Remote +pre-commit hooks — SHA-pin, don't tag-pin"). A `rev: v5.0.0` on an +exempt repo is a gap; a `rev: <40-char hex> # frozen: v5.0.0` is +the shape. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'pre-commit-config' +APPLIES = 'product,canonical,personal' + +EXEMPT_REPOS = {'https://github.com/pre-commit/pre-commit-hooks'} + +REPO_RE = re.compile(r'^[ \t]*-[ \t]*repo:[ \t]*(.*)$') +REV_LINE_RE = re.compile(r'^[ \t]*rev:[ \t]+(.*)$') +SHA_RE = re.compile(r'^[0-9a-f]{40}$') + + +def scan_revs(text: str) -> tuple[int, int]: + """Return (tool_revs, tag_pinned_exempt_revs). + + tool_revs counts rev: entries on non-exempt repos (duplicating the + dependency-group source of truth). + + tag_pinned_exempt_revs counts rev: entries on the exempt carve-out + that aren't full 40-char SHAs — the carve-out still has to be + SHA-pinned, same discipline as gha-sha-pinning. + """ + tool_revs = 0 + tag_pinned = 0 + cur = '' + for line in text.splitlines(): + m = REPO_RE.match(line) + if m: + cur = m.group(1).strip().replace('"', '').replace("'", '').rstrip() + continue + rm = REV_LINE_RE.match(line) + if not rm: + continue + val = rm.group(1) + # Strip trailing comment (e.g. `# frozen: v5.0.0`) before matching. + val = re.sub(r'[ \t]*#.*$', '', val) + val = val.replace('"', '').replace("'", '').strip() + if cur in EXEMPT_REPOS: + if not SHA_RE.match(val): + tag_pinned += 1 + else: + tool_revs += 1 + return tool_revs, tag_pinned + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + if ( + not Path('.pre-commit-config.yaml').is_file() + and not Path('.pre-commit-config.yml').is_file() + ): + emit_check( + CHECK_ID, + 'fail', + 'No .pre-commit-config.yaml found.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a minimal config for the languages in use; hooks should use language: ' + 'system to invoke tools from the uv-locked dependency-groups.' + ), + }, + ) + return EXIT_FAIL + + config = '.pre-commit-config.yaml' + if Path('.pre-commit-config.yml').is_file(): + config = '.pre-commit-config.yml' + + try: + text = Path(config).read_text(errors='replace') + except OSError: + text = '' + versioned_revs, tag_pinned_revs = scan_revs(text) + + if versioned_revs > 0: + emit_check( + CHECK_ID, + 'fail', + f'Pre-commit config pins {versioned_revs} rev: version(s). Cycle convention is to ' + f'invoke tools via language: system from pyproject.toml [dependency-groups].', + {'config': config, 'versioned_revs': versioned_revs}, + { + 'kind': 'judgement', + 'human_review': ( + 'Move tool versions to pyproject.toml [dependency-groups]; replace each ' + 'pinned ' + 'hook with a language: system equivalent. Reference: pytest-jubilant#86.' + ), + }, + ) + return EXIT_FAIL + + if tag_pinned_revs > 0: + emit_check( + CHECK_ID, + 'fail', + f'Pre-commit config has {tag_pinned_revs} rev: entry(s) pinned by tag rather than ' + f'full SHA. Cycle convention (see decisions.md § Remote pre-commit hooks): SHA-pin, ' + f"don't tag-pin — same discipline as gha-sha-pinning.", + {'config': config, 'tag_pinned_revs': tag_pinned_revs}, + { + 'kind': 'judgement', + 'human_review': ( + 'Resolve each tag-pinned rev: to its 40-char commit SHA and add a `# frozen: ' + '` trailing comment. Dependabot pre-commit ecosystem will bump the SHA.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'pass', + ( + 'Pre-commit config present, no tool versions duplicated in rev: fields, and any ' + 'surviving rev: is SHA-pinned.' + ), + {'config': config}, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/repo_settings.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/repo_settings.py new file mode 100755 index 0000000..09628c2 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/repo_settings.py @@ -0,0 +1,322 @@ +"""Check: GitHub repo settings are either declared in canonical-repo-automation +(CRA) — the Terraform/Terragrunt control plane that owns repo settings for +Charm Tech — or, for repos not enrolled in CRA, set manually to the baseline. + +Tier coverage: product, canonical, personal. + - product / canonical: prefer CRA enrolment; otherwise live settings must + match the baseline. + - personal: live settings only (CRA does not manage personal repos). + +Mandate: cycle baseline — canonical-repo-automation (CRA) is the real +control plane for Charm Tech repo settings. CRA already declares: + - allowed_actions = "selected" + - private vulnerability reporting on (group-wide) + - Dependabot security updates on (group-wide) + - squash-only merges, delete-branch-on-merge + - secret scanning + push protection (PR #812 group-wide) +Without CRA the same posture must be set per-repo via Settings or `gh api`. + +Baseline settings checked (live): + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - security_and_analysis.secret_scanning.status=enabled + - security_and_analysis.secret_scanning_push_protection.status=enabled + - security_and_analysis.dependabot_security_updates.status=enabled + - private vulnerability reporting enabled + - allowed actions != "all" (selected / local_only) — canonical/product only + +CRA enrolment is detected via `gh api` against +canonical/canonical-repo-automation. If gh is unavailable or the query fails, +the check emits `unknown` and falls back to checking live settings. +""" + +from __future__ import annotations + +import json +import shutil +import sys + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + EXIT_UNKNOWN, + emit_check, + origin_url, + parse_tier, + run, + tier_applies, +) + +CHECK_ID = 'repo-settings' +APPLIES = 'product,canonical,personal' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + url = origin_url() + slug = url[len('https://github.com/') :] if url.startswith('https://github.com/') else url + + if not slug or slug == url: + emit_check(CHECK_ID, 'unknown', 'Could not parse owner/repo from origin URL.') + return EXIT_UNKNOWN + + owner = slug.split('/', 1)[0] + name = slug.rsplit('/', 1)[-1] + + if not shutil.which('gh'): + emit_check( + CHECK_ID, + 'unknown', + 'gh CLI not installed; cannot inspect live repo settings or CRA enrolment.', + ) + return EXIT_UNKNOWN + + # --- 1. CRA enrolment --- + managed_by_cra = False + cra_path = '' + if tier != 'personal' and owner == 'canonical': + r = run([ + 'gh', + 'api', + 'repos/canonical/canonical-repo-automation', + '--jq', + '.default_branch', + ]) + cra_branch = r.stdout.strip() if r.returncode == 0 else 'main' + if not cra_branch: + cra_branch = 'main' + r2 = run([ + 'gh', + 'api', + f'repos/canonical/canonical-repo-automation/git/trees/{cra_branch}?recursive=1', + '--jq', + f'.tree[].path | select(test("(^|/)repos/{name}/"))', + ]) + if r2.returncode == 0: + first = '' + for ln in r2.stdout.splitlines(): + if ln.strip(): + first = ln.strip() + break + if first: + managed_by_cra = True + cra_path = first + + # --- 2. Live settings --- + r = run(['gh', 'api', f'repos/{slug}']) + if r.returncode != 0: + emit_check( + CHECK_ID, + 'unknown', + f'Could not fetch repos/{slug} via gh api (auth scope or network).', + {'slug': slug}, + ) + return EXIT_UNKNOWN + + try: + repo_json = json.loads(r.stdout) + except json.JSONDecodeError: + emit_check( + CHECK_ID, + 'unknown', + f'Could not fetch repos/{slug} via gh api (auth scope or network).', + {'slug': slug}, + ) + return EXIT_UNKNOWN + + def get(obj, *keys, default='unknown'): + cur = obj + for k in keys: + if not isinstance(cur, dict): + return default + cur = cur.get(k) + if cur is None: + return default + return cur + + def bool_or_str(v): + if v is True: + return 'true' + if v is False: + return 'false' + if v is None: + return 'null' + return str(v) + + squash = bool_or_str(repo_json.get('allow_squash_merge')) + merge_commit = bool_or_str(repo_json.get('allow_merge_commit')) + rebase = bool_or_str(repo_json.get('allow_rebase_merge')) + delete_on_merge = bool_or_str(repo_json.get('delete_branch_on_merge')) + + sa = repo_json.get('security_and_analysis') or {} + ss_secret = (sa.get('secret_scanning') or {}).get('status') or 'unknown' + ss_push = (sa.get('secret_scanning_push_protection') or {}).get('status') or 'unknown' + ss_dep = (sa.get('dependabot_security_updates') or {}).get('status') or 'unknown' + + pvr = 'unknown' + r = run(['gh', 'api', f'repos/{slug}/private-vulnerability-reporting']) + if r.returncode == 0: + try: + pvr_json = json.loads(r.stdout) + pvr = bool_or_str(pvr_json.get('enabled')) + except json.JSONDecodeError: + pvr = 'unknown' + + allowed_actions = 'unknown' + if tier != 'personal': + r = run(['gh', 'api', f'repos/{slug}/actions/permissions']) + if r.returncode == 0: + try: + perms_json = json.loads(r.stdout) + allowed_actions = perms_json.get('allowed_actions') or 'unknown' + except json.JSONDecodeError: + allowed_actions = 'unknown' + + problems: list[str] = [] + unverifiable: list[str] = [] + + if squash != 'true': + problems.append('allow_squash_merge != true') + if merge_commit != 'false': + problems.append('allow_merge_commit != false') + if rebase != 'false': + problems.append('allow_rebase_merge != false') + if delete_on_merge != 'true': + problems.append('delete_branch_on_merge != true') + + def verify_admin_field(name: str, value: str, want: str) -> None: + if value in ('unknown', 'null', ''): + unverifiable.append(f'{name} (token lacks admin scope)') + elif value == want: + return + else: + problems.append(f'{name} != {want} ({value})') + + verify_admin_field('secret_scanning', ss_secret, 'enabled') + verify_admin_field('secret_scanning_push_protection', ss_push, 'enabled') + verify_admin_field('dependabot_security_updates', ss_dep, 'enabled') + verify_admin_field('private_vulnerability_reporting', pvr, 'true') + + if tier != 'personal': + if allowed_actions in ('selected', 'local_only'): + pass + elif allowed_actions in ('unknown', 'null', ''): + unverifiable.append('allowed_actions (token lacks admin scope)') + else: + problems.append( + f"allowed_actions={allowed_actions} (expected 'selected' or 'local_only')" + ) + + unverifiable_note = '' + if unverifiable: + unverifiable_note = f' (unverifiable from this token: {"; ".join(unverifiable)})' + + evidence = { + 'slug': slug, + 'managed_by_cra': managed_by_cra, + 'cra_path': cra_path, + 'allow_squash_merge': squash, + 'allow_merge_commit': merge_commit, + 'allow_rebase_merge': rebase, + 'delete_branch_on_merge': delete_on_merge, + 'secret_scanning': ss_secret, + 'push_protection': ss_push, + 'dependabot_security_updates': ss_dep, + 'private_vulnerability_reporting': pvr, + 'allowed_actions': allowed_actions, + } + + if managed_by_cra and not problems: + emit_check( + CHECK_ID, + 'pass', + f'Settings declared in CRA ({cra_path}); merge/branch-deletion posture matches the ' + f'baseline{unverifiable_note}.', + evidence, + ) + return EXIT_PASS + + if managed_by_cra and problems: + joined = '; '.join(problems) + emit_check( + CHECK_ID, + 'fail', + f'Repo is declared in CRA ({cra_path}) but live settings drift from the baseline: ' + f'{joined}. Run a CRA apply to reconcile; do not patch live settings directly.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Drift between CRA-declared and live settings. Re-apply CRA for the relevant ' + 'group rather than mutating GitHub directly — direct patches will be ' + 'overwritten on the next apply.' + ), + }, + ) + return EXIT_FAIL + + if not problems: + if tier == 'personal': + emit_check( + CHECK_ID, + 'pass', + f'Live settings match the baseline (personal tier — CRA enrolment not ' + f'expected){unverifiable_note}.', + evidence, + ) + return EXIT_PASS + emit_check( + CHECK_ID, + 'pass', + f'Live settings match the baseline. Not declared in CRA — confirm whether this repo ' + f'should be enrolled in canonical-repo-automation{unverifiable_note}.', + evidence, + ) + return EXIT_PASS + + joined = '; '.join(problems) + if tier == 'personal': + emit_check( + CHECK_ID, + 'fail', + f'Live settings drift from baseline: {joined}.', + evidence, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/apply-repo-settings.py', + 'human_review': ( + 'Review each setting before applying; the fix script patches the repo via gh ' + 'api.' + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, + 'fail', + f'Repo is NOT declared in canonical-repo-automation and live settings drift from ' + f'baseline: {joined}. Either enrol the repo in CRA (preferred for canonical-owned ' + f'repos) or apply the settings manually.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Preferred: open a CRA PR declaring this repo under the appropriate ' + 'groups//repos/ tree so settings are managed centrally. Fallback (if CRA ' + 'enrolment is intentionally out of scope): run ' + 'scripts/fixes/apply-repo-settings.py ' + 'to patch the live settings via gh api.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sbom_workflow.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sbom_workflow.py new file mode 100755 index 0000000..7387a47 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sbom_workflow.py @@ -0,0 +1,203 @@ +"""Check: SBOM workflow / manifest present and triggered per cycle. +Tier coverage: product only. + +Mandate: SEC0027 (every release type). Generated via sbom-request.canonical.com. +Some repos carry an in-repo manifest (.sbomber-manifest-*.yaml); some +integrate the SBOM request as a CI workflow step. + +A `workflow_dispatch:`-only workflow satisfies presence but not cadence — +SBOM must be regenerated every release / cycle. Pass requires at least one +of the cadence triggers: `release`, `schedule`, or tag-pushes +(`push: tags:` or `push: branches:` + `tags:` filter). Manifests-only repos +are accepted unconditionally — the manifest is consumed by an external +sbom-request pipeline that owns its own cadence. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'sbom-workflow' +APPLIES = 'product' + +WORKFLOW_KEY_RE = re.compile(r'sbom-request|sbomber|sbom-secscan', re.IGNORECASE) + + +def find_manifests() -> list[str]: + hits: list[str] = [] + gh = Path('.github') + root_candidates: list[Path] = [] + # maxdepth 2: .github + .github/ + if gh.is_dir(): + for p in gh.iterdir(): + if p.is_file() and ( + re.search(r'sbomber-manifest.*\.ya?ml$', p.name) + or re.search(r'^sbom.*\.ya?ml$', p.name) + ): + root_candidates.append(p) + if p.is_dir(): + for p2 in p.iterdir(): + if p2.is_file() and ( + re.search(r'sbomber-manifest.*\.ya?ml$', p2.name) + or re.search(r'^sbom.*\.ya?ml$', p2.name) + ): + root_candidates.append(p2) + for p in root_candidates[:3]: + hits.append(str(p)) + return hits + + +def find_workflow() -> str: + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + return '' + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if WORKFLOW_KEY_RE.search(p.read_text(errors='replace')): + return str(p) + except OSError: + continue + return '' + + +def cadence_check_yaml(workflow: str) -> tuple[bool, str]: + """Return (ok, reason). reason is empty on ok.""" + try: + import yaml # type: ignore + except Exception: + return cadence_check_grep(workflow) + try: + with open(workflow) as f: + doc = yaml.safe_load(f) + except Exception as e: + return False, f'could not parse workflow triggers (PARSE_ERROR {e})' + if not isinstance(doc, dict): + return False, 'could not parse workflow triggers (UNKNOWN_ON_SHAPE)' + on = doc.get(True) + if on is None: + on = doc.get('on') + if on is None: + return False, 'could not parse workflow triggers (MISSING_ON)' + if isinstance(on, str): + on = {on: None} + elif isinstance(on, list): + on = {k: None for k in on} + if not isinstance(on, dict): + return False, 'could not parse workflow triggers (UNKNOWN_ON_SHAPE)' + triggers = set(on.keys()) + if 'release' in triggers or 'schedule' in triggers: + return True, '' + push = on.get('push') + if isinstance(push, dict) and ('tags' in push or 'tags-ignore' in push): + return True, '' + joined = ','.join(sorted(str(t) for t in triggers)) + return ( + False, + f'workflow triggers ({joined}) include no cadence trigger (release / schedule / push: ' + f'tags)', + ) + + +def cadence_check_grep(workflow: str) -> tuple[bool, str]: + try: + text = Path(workflow).read_text(errors='replace') + except OSError: + return ( + False, + ( + 'no release/schedule/push-tags trigger found (grep fallback; install ' + 'python3+PyYAML ' + 'for accurate check)' + ), + ) + if re.search(r'^[ \t]*(release|schedule):', text, re.MULTILINE): + return True, '' + if re.search(r'push:[ \t]*\n[ \t]+tags:', text): + return True, '' + return ( + False, + ( + 'no release/schedule/push-tags trigger found (grep fallback; install python3+PyYAML ' + 'for ' + 'accurate check)' + ), + ) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + manifests = find_manifests() + workflow = find_workflow() + + if not manifests and not workflow: + emit_check( + CHECK_ID, + 'fail', + 'No SBOM workflow or manifest found.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Request SBOM via sbom-request.canonical.com Web UI or REST API; store in the ' + 'SSDLC Artifacts directory and request review in ~SSDLC. Add a CI step that ' + 'triggers SBOM generation per release if useful.' + ), + }, + ) + return EXIT_FAIL + + cadence_ok = True + cadence_reason = '' + if workflow: + cadence_ok, cadence_reason = cadence_check_yaml(workflow) + + parts = list(manifests) + ([workflow] if workflow else []) + found = ','.join(parts).strip().strip(',') + + if cadence_ok: + emit_check( + CHECK_ID, + 'pass', + 'SBOM workflow / manifest present with per-cycle cadence.', + {'found': found}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + f'SBOM workflow present but cadence not guaranteed: {cadence_reason}.', + {'found': found, 'workflow': workflow}, + { + 'kind': 'judgement', + 'human_review': ( + 'SBOM must regenerate per release/cycle. Add `on: release: types: [published]` ' + '(preferred for release-cut workflows) or `on: schedule:` (for unreleased ' + 'products) ' + 'or `on: push: tags: [v*]`. workflow_dispatch alone is not sufficient.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0030_coverage.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0030_coverage.py new file mode 100755 index 0000000..078f39b --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0030_coverage.py @@ -0,0 +1,108 @@ +"""Check: SEC0030 V1.3 Security Documentation coverage. +Tier coverage: product only. + +Looks for either docs/explanation/security.md (Sphinx-stack convention) +or an expanded SECURITY.md that covers the seven V1.3 sections. +The check is heuristic — it looks for headings, not deep content. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'sec0030-coverage' +APPLIES = 'product' + +REQUIRED = [ + ('Product architecture', 'Product architecture'), + ('Secure by design', 'Secure by design'), + ('Cryptography', 'Crypt'), + ('Hardening', 'Hardening'), + ('Logging and monitoring', 'Logging|Monitoring'), + ('Decommissioning', 'Decommissioning'), + ('Security lifecycle', 'Security lifecycle|Security updates'), +] + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + target = '' + for path in ('docs/explanation/security.md', 'docs/security.md', 'SECURITY.md'): + if Path(path).is_file(): + target = path + break + + if not target: + emit_check( + CHECK_ID, + 'fail', + ( + 'No security documentation found (docs/explanation/security.md or expanded ' + 'SECURITY.md).' + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Either author docs/explanation/security.md (preferred) or expand SECURITY.md ' + 'to cover the seven SEC0030 V1.3 sections.' + ), + }, + ) + return EXIT_FAIL + + text = Path(target).read_text(errors='replace') + missing_parts: list[str] = [] + for label, pattern in REQUIRED: + rx = re.compile(rf'^#{{1,4}} .*({pattern})', re.IGNORECASE | re.MULTILINE) + if not rx.search(text): + missing_parts.append(label) + + if not missing_parts: + emit_check( + CHECK_ID, + 'pass', + f'SEC0030 V1.3 coverage looks complete in {target}.', + {'path': target}, + ) + return EXIT_PASS + + trimmed = '; '.join(missing_parts) + emit_check( + CHECK_ID, + 'fail', + f'SEC0030 V1.3 missing section(s) in {target}: {trimmed}', + {'path': target, 'missing': trimmed}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add the missing section(s). See operator#2571, pebble#893, jubilant#332, ' + 'charm-ubuntu#87 for reference patterns (sentence-case headers, Mermaid diagrams, ' + 'bulleted hardening with To-harden intro, channels-bullet-list at end of ' + 'Reporting).' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0045_events.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0045_events.py new file mode 100755 index 0000000..02e1575 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/sec0045_events.py @@ -0,0 +1,142 @@ +"""Check: SEC0045 Security Event Logging — heuristic. +Tier coverage: product only. + +Applicability is per-product (see references/decisions.md). Where the +per-product disposition is settled we short-circuit. For other products we +look for evidence the OWASP Application Logging Vocabulary has been +adopted — either by name reference (OWASP / owasp-logger / securitylog) or +by emitted event-name tokens (authn_*, authz_*, sys_*, user_created/updated, +excessive_use, malicious_*, input_validation_*). + +Output is informational: a pass means evidence exists, NOT that the events +match the doc's required set. A fail means the agent should confirm whether +the product genuinely has no auth/admin/user surface, or whether logging is +missing. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + origin_url, + parse_tier, + tier_applies, +) + +CHECK_ID = 'sec0045-events' +APPLIES = 'product' + +NAMED_RE = re.compile(r'OWASP|owasp-logger|securitylog|security_event|security-event') +OWASP_RE = re.compile( + r'authn_(login|password|token|impersonation|create|sso|2fa)_(succ|fail|change|created|revoked|expired|lock|use|unlock)' + r'|authz_(fail|change|admin|impersonation)' + r'|excessive_use' + r'|input_validation_(fail)' + r'|malicious_(direct_reference|attack_tool|cors|excess_use)' + r'|sys_(startup|shutdown|restart|crash|monitor_disabled|monitor_enabled|config_change)' + r'|user_(created|updated|deleted|archived|suspended)' + r'|session_(created|expired|use_after_expire|hijacked|renewed)' +) + + +def find_matches(pattern: re.Pattern[str], max_hits: int = 5) -> list[str]: + hits: list[str] = [] + for ext in ('*.go', '*.py'): + for p in Path('.').rglob(ext): + if not p.is_file(): + continue + try: + text = p.read_text(errors='replace') + except OSError: + continue + if pattern.search(text): + # strip leading ./ to match grep -rEl output shape + s = str(p) + if not s.startswith('./') and not s.startswith('/'): + s = './' + s + hits.append(s) + if len(hits) >= max_hits: + return hits + return hits + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + url = origin_url() + slug = url[len('https://github.com/') :] if url.startswith('https://github.com/') else url + + if slug == 'canonical/operator': + emit_check(CHECK_ID, 'pass', 'SEC0045 done long ago via canonical/operator#1905.') + return EXIT_PASS + if slug in ('canonical/jubilant', 'canonical/pytest-jubilant'): + emit_check(CHECK_ID, 'na', 'Out of scope: no user/admin/auth surface.') + return EXIT_NA + if slug == 'canonical/charmlibs': + emit_check(CHECK_ID, 'na', 'Applicable but deferred to a future cycle.') + return EXIT_NA + + named_evidence = find_matches(NAMED_RE) + token_evidence = find_matches(OWASP_RE) + + if token_evidence: + found = ','.join(token_evidence) + emit_check( + CHECK_ID, + 'pass', + 'Code emits OWASP Application Logging Vocabulary event tokens.', + {'evidence_files': found, 'signal': 'event-name-tokens'}, + ) + return EXIT_PASS + + if named_evidence: + found = ','.join(named_evidence) + emit_check( + CHECK_ID, + 'pass', + ( + 'Code references SEC0045 / OWASP security event logging by name (but no specific ' + 'event-name tokens detected — confirm the 17 OWASP events are covered).' + ), + {'evidence_files': found, 'signal': 'name-reference-only'}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + ( + 'No SEC0045 security-event logging detected: neither OWASP-named files nor any ' + 'event-name tokens ' + '(authn_/authz_/sys_/user_/session_/excessive_use/malicious_/input_validation_). ' + 'Confirm applicability per the per-product disposition in references/decisions.md.' + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'If the product emits user/admin/auth events, implement the OWASP Application ' + 'Logging Vocabulary events in JSON (or logfmt) per canonical/operator#1905 / ' + 'canonical/concierge#208. The 17 events span authn_*, authz_*, sys_*, user_*, ' + 'session_*, plus excessive_use, malicious_*, input_validation_*.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/secscan_workflow.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/secscan_workflow.py new file mode 100755 index 0000000..bea1b3c --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/secscan_workflow.py @@ -0,0 +1,226 @@ +"""Check: canonical-secscan-client (or equivalent) workflow present, with the +SSDLC identification details wired up so results land in the long-term scan +registry. +Tier coverage: product only. + +Mandate: SEC0025. Run at least once per cycle per product with SSDLC +identification. + +Two flavours are accepted: + +1. **sbomber-driven** (Charm Tech default): the workflow checks out or + invokes `canonical/sbomber`, and the repo carries one or more + `.sbomber-manifest*.yaml` files (root or under .github/). SSDLC + identification lives in `ssdlc_params:` blocks per artifact inside the + manifest; the secscan client is enabled via `clients.secscan` in the + manifest. Pass requires: workflow + at least one manifest with both + `clients.secscan` and per-artifact `ssdlc_params`. + +2. **Direct canonical-secscan-client**: the workflow runs the client + directly (or via cs-github-actions / starflow). Pass requires the + --ssdlc-product-name / --ssdlc-cycle CLI parameters. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'secscan-workflow' +APPLIES = 'product' + +SBOMBER_RE = re.compile(r'canonical/sbomber|\./sbomber|sbomber/sbomber', re.IGNORECASE) +DIRECT_RE = re.compile( + r'canonical-secscan-client|run-secscan|sbom-secscan|scan-python', re.IGNORECASE +) +SECSCAN_KEY_RE = re.compile(r'^\s*secscan\s*:', re.MULTILINE) +SSDLC_KEY_RE = re.compile(r'^\s*ssdlc_params\s*:', re.MULTILINE) +SSDLC_CLI_RE = re.compile(r'ssdlc-product-name|ssdlc-cycle') + + +def find_first(pattern: re.Pattern[str], files: list[Path]) -> str: + for p in files: + try: + if pattern.search(p.read_text(errors='replace')): + return str(p) + except OSError: + continue + return '' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, + 'fail', + 'No .github/workflows directory.', + {}, + {'kind': 'judgement', 'human_review': 'Set up workflows and wire secscan.'}, + ) + return EXIT_FAIL + + workflows = sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))) + + sbomber_workflow = find_first(SBOMBER_RE, workflows) + direct_workflow = find_first(DIRECT_RE, workflows) + + if not sbomber_workflow and not direct_workflow: + emit_check( + CHECK_ID, + 'fail', + 'No secscan workflow found.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Reference: canonical/sbomber composite action (Charm Tech default), ' + 'canonical/cs-github-actions run-secscan, or canonical/starflow scan-python. ' + 'Use the --batch instance; GitHub private runners are allow-listed.' + ), + }, + ) + return EXIT_FAIL + + if sbomber_workflow: + workflow_hit = sbomber_workflow + # Find manifests up to 3 levels deep, excluding .git + manifests: list[str] = [] + for pattern in ('.sbomber-manifest*.yaml', '.sbomber-manifest*.yml'): + for p in Path('.').rglob(pattern): + # Depth check: <=3 components from root + parts = p.parts + if len(parts) > 3: + continue + if any(part == '.git' for part in parts): + continue + manifests.append(str(p)) + + if not manifests: + emit_check( + CHECK_ID, + 'fail', + ( + 'sbomber workflow present but no .sbomber-manifest*.yaml found at repo root ' + 'or ' + 'under .github/.' + ), + {'workflow': workflow_hit}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a .sbomber-manifest-.yaml describing artifacts, with ' + 'clients.secscan enabled and ssdlc_params per artifact. See ' + 'canonical/sbomber/examples/all/manifest.yaml.' + ), + }, + ) + return EXIT_FAIL + + problems: list[str] = [] + has_secscan = False + has_ssdlc = False + for m in manifests: + try: + text = Path(m).read_text(errors='replace') + except OSError: + continue + if SECSCAN_KEY_RE.search(text): + has_secscan = True + if SSDLC_KEY_RE.search(text): + has_ssdlc = True + + if not has_secscan: + problems.append('no manifest declares clients.secscan') + if not has_ssdlc: + problems.append('no manifest carries per-artifact ssdlc_params') + + evidence = { + 'workflow': workflow_hit, + 'driver': 'sbomber', + 'manifests': manifests, + } + + if not problems: + emit_check( + CHECK_ID, + 'pass', + ( + 'sbomber workflow present; manifest enables secscan client and carries ' + 'ssdlc_params.' + ), + evidence, + ) + return EXIT_PASS + + joined = '; '.join(problems) + emit_check( + CHECK_ID, + 'fail', + f'sbomber workflow present but manifest incomplete: {joined}.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'In the .sbomber-manifest*.yaml, ensure clients.secscan is enabled and every ' + 'artifact declares ssdlc_params (name/version/channel) — these are what the ' + 'SSDLC scan registry indexes.' + ), + }, + ) + return EXIT_FAIL + + # Direct client path. + workflow_hit = direct_workflow + try: + text = Path(workflow_hit).read_text(errors='replace') + except OSError: + text = '' + if SSDLC_CLI_RE.search(text): + emit_check( + CHECK_ID, + 'pass', + 'secscan workflow present with SSDLC identification parameters.', + {'workflow': workflow_hit, 'driver': 'canonical-secscan-client'}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'secscan workflow present but --ssdlc-* identification parameters missing.', + {'workflow': workflow_hit, 'driver': 'canonical-secscan-client'}, + { + 'kind': 'judgement', + 'human_review': ( + 'Pass --ssdlc-product-name, --ssdlc-cycle, --ssdlc-product-channel, ' + '--ssdlc-product-version so results land in the long-term SSDLC scan registry. ' + '(Or ' + 'migrate to canonical/sbomber and move identification into the manifest ' + 'ssdlc_params blocks.)' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/security_md.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/security_md.py new file mode 100755 index 0000000..890d7d1 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/security_md.py @@ -0,0 +1,93 @@ +"""Check: SECURITY.md exists and references the Ubuntu disclosure policy. +Tier coverage: all (product, canonical, personal). + +Mandate: SEC0025 §General Requirements + SEC0026 (Canonical-internal); +best practice for personal-tier. + +Pass: SECURITY.md present AND links to ubuntu.com/security/disclosure-policy + OR to security@ubuntu.com / security@canonical.com +Fail: SECURITY.md missing, OR present but no disclosure-policy link/contact +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'security-md' +APPLIES = 'product,canonical,personal' + +PATTERN = re.compile( + r'ubuntu\.com/security/disclosure-policy|security@(ubuntu|canonical)\.com|security/advisories', + re.IGNORECASE, +) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + p = Path('SECURITY.md') + if not p.is_file(): + emit_check( + CHECK_ID, + 'fail', + 'SECURITY.md is missing.', + {}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/add-security-md.py', + 'human_review': 'Customise the disclosure contact and supported-versions table.', + }, + ) + return EXIT_FAIL + + text = p.read_text(errors='replace') + if PATTERN.search(text): + lines = text.count('\n') + emit_check( + CHECK_ID, + 'pass', + 'SECURITY.md present and references the Ubuntu disclosure policy.', + {'path': 'SECURITY.md', 'lines': lines}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + ( + 'SECURITY.md present but does not reference the Ubuntu disclosure policy or a ' + 'security ' + 'contact.' + ), + {'path': 'SECURITY.md'}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a Reporting section linking https://ubuntu.com/security/disclosure-policy ' + 'and ' + 'the project security contact.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/threat_model_drive.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/threat_model_drive.py new file mode 100755 index 0000000..968e2dc --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/threat_model_drive.py @@ -0,0 +1,53 @@ +"""Check: Threat model — informational only. +Tier coverage: product only. + +Threat models live in the central SSDLC Artifacts Drive. This check +emits an informational note prompting the agent to confirm the +Drive sheet is current for the cycle. +""" + +from __future__ import annotations + +import sys + +from ..common import ( + EXIT_NA, + EXIT_PASS, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'threat-model-drive' +APPLIES = 'product' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + emit_check( + CHECK_ID, + 'unknown', + ( + 'Cannot verify from the repo — threat models live in the SSDLC Artifacts Drive. ' + 'Confirm ' + 'a refreshed model exists for this cycle.' + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'SEC0028: refresh every release cycle; demonstrate no unacceptable residual risk; ' + 'any accepted risk needs a Risk Acceptance Form. Charm SDK consolidated sheet ' + 'covers ops/ops-scenario/ops-tracing/jubilant/concierge; pebble has its own sheet.' + ), + }, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tiobe_config.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tiobe_config.py new file mode 100755 index 0000000..3045217 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tiobe_config.py @@ -0,0 +1,179 @@ +"""Check: TIOBE TICS workflow present, wired with the auth token, and the +language-specific linters TICS needs are declared as project dependencies. +Tier coverage: product only. + +Mandate: SEC0024 (Static Code Analysis). TIOBE TICS is the required +SCA tool for SSDLC satisfaction; additional scanners are encouraged +but do not substitute. + +Pass: A workflow under .github/workflows/ invokes tiobe/tics-github-action, + references `secrets.TICSAUTHTOKEN`, and the per-language linters + (Python: flake8 + pylint; Go: staticcheck) are visible somewhere + in the repo (workflow install step, pyproject.toml dep group, + Makefile, or go.mod tooling). +Fail: Any of the above missing. + +Notes: This script does NOT verify the TQI target spreadsheet entry, the +Coverage XML artefact path, or the actual TICS dashboard score (all live +outside the repo). The `tqi-security-target` check covers the spreadsheet +entry; coverage-XML is left as a per-repo judgement. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'tiobe-config' +APPLIES = 'product' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, + 'fail', + 'No .github/workflows/ directory; cannot host TIOBE TICS workflow.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a tiobe.yaml workflow per https://canonical-tiobe-docs.canonical.com/ — ' + 'needs the self-hosted tiobe runner and viewer config selection.' + ), + }, + ) + return EXIT_FAIL + + hits: list[str] = [] + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + if 'tiobe/tics-github-action' in p.read_text(errors='replace'): + hits.append(str(p)) + except OSError: + continue + + if not hits: + emit_check( + CHECK_ID, + 'fail', + 'No TIOBE TICS workflow found under .github/workflows/.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a tiobe.yaml workflow using the self-hosted tiobe runner and the ' + 'appropriate viewer config (GoProjects for Go; default for Python).' + ), + }, + ) + return EXIT_FAIL + + workflow = hits[0] + problems: list[str] = [] + + workflow_text = Path(workflow).read_text(errors='replace') + if not re.search(r'secrets\.TICSAUTHTOKEN', workflow_text): + problems.append('workflow does not reference secrets.TICSAUTHTOKEN') + + # Determine language. + language = 'unknown' + if ( + Path('pyproject.toml').is_file() + or any(Path('.').glob('*.py')) + or Path('requirements.txt').is_file() + or Path('setup.cfg').is_file() + ): + language = 'python' + elif Path('go.mod').is_file(): + language = 'go' + + # Aggregate content from candidate files. + def gather() -> str: + parts = [workflow_text] + for f in ( + 'pyproject.toml', + 'requirements.txt', + 'requirements-dev.txt', + 'setup.cfg', + 'Makefile', + 'go.mod', + 'tools.go', + ): + p = Path(f) + if p.is_file(): + try: + parts.append(p.read_text(errors='replace')) + except OSError: + pass + return '\n'.join(parts) + + haystack = gather() + + def linter_hit(pattern: str) -> bool: + return re.search(pattern, haystack, re.IGNORECASE) is not None + + if language == 'python': + if not linter_hit(r'(^|[^a-z])flake8([^a-z]|$)'): + problems.append('flake8 not declared in workflow/pyproject/requirements/Makefile') + if not linter_hit(r'(^|[^a-z])pylint([^a-z]|$)'): + problems.append('pylint not declared in workflow/pyproject/requirements/Makefile') + elif language == 'go': + if not linter_hit(r'staticcheck'): + problems.append('staticcheck not declared in workflow/Makefile/go.mod/tools.go') + + evidence = {'workflow': workflow, 'language': language} + + if not problems: + emit_check( + CHECK_ID, + 'pass', + 'TIOBE TICS workflow present, TICSAUTHTOKEN wired, and language linters declared.', + evidence, + ) + return EXIT_PASS + + joined = '; '.join(problems) + emit_check( + CHECK_ID, + 'fail', + f'TIOBE TICS workflow present but incomplete: {joined}.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Add secrets.TICSAUTHTOKEN to the workflow env (TICS publishes nothing without ' + 'it). ' + 'For Python repos, declare flake8 and pylint in a [dependency-groups] block (or ' + 'install them in the workflow). For Go repos, add staticcheck via a tools.go ' + 'entry, ' + 'Makefile target, or workflow install step. Also confirm a Cobertura coverage ' + 'artefact is produced before the TICS step runs — that is repo-specific and not ' + 'auto-verified here.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tqi_security_target.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tqi_security_target.py new file mode 100755 index 0000000..f48cc0a --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/tqi_security_target.py @@ -0,0 +1,52 @@ +"""Check: TQI security target — informational only. +Tier coverage: product only. + +The TQI target lives in the central *TiCS Targets 26.10* spreadsheet, +not the repo. This check just emits an informational note prompting +the agent to verify the target is recorded for the cycle. +""" + +from __future__ import annotations + +import sys + +from ..common import ( + EXIT_NA, + EXIT_PASS, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'tqi-security-target' +APPLIES = 'product' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + emit_check( + CHECK_ID, + 'unknown', + ( + 'Cannot verify from the repo — the TQI security target lives in the *TiCS Targets ' + '26.10* spreadsheet. Confirm a target is recorded for this product.' + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Set/verify the per-repo Security metric (TQI) target in *TiCS Targets 26.10* ' + 'by 30 ' + 'June.' + ), + }, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/trusted_publishing.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/trusted_publishing.py new file mode 100755 index 0000000..33d4171 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/trusted_publishing.py @@ -0,0 +1,145 @@ +"""Check: every PyPI publish path uses Trusted Publishing (OIDC), not a long-lived +API token. Detects: + - `pypa/gh-action-pypi-publish` invocations — pass requires NO `password:` + or `username:` input, and the surrounding job (or workflow) must declare + `id-token: write`. + - `twine upload` invocations — fail; twine is the long-lived-token path. + +Emits `na` when no PyPI publish path is present (Python project that doesn't +publish, or non-Python repo). Tier coverage: all tiers — anyone publishing +to PyPI from GitHub Actions should use Trusted Publishing. + +Reference: BASELINE.md "All Python-publishing repos already use Trusted +Publishing (OIDC id-token: write + pypa/gh-action-pypi-publish)". +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'trusted-publishing' +APPLIES = 'product,canonical,personal' + +TOKEN_INPUT_RE = re.compile(r'^[ \t]*(password|username):', re.MULTILINE) +ID_TOKEN_WRITE_RE = re.compile(r'^[ \t]*id-token:[ \t]*write\b', re.MULTILINE) +TWINE_RE = re.compile(r'(^|[ \t])twine[ \t]+upload\b', re.MULTILINE) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/workflows directory; no publish workflow to audit.') + return EXIT_NA + + workflows = sorted([ + str(p) for p in wf_dir.iterdir() if p.is_file() and p.suffix in ('.yml', '.yaml') + ]) + if not workflows: + emit_check(CHECK_ID, 'na', 'No workflows under .github/workflows.') + return EXIT_NA + + publish_files: list[str] = [] + twine_files: list[str] = [] + bad_token_files: list[str] = [] + missing_id_token_files: list[str] = [] + + for wf in workflows: + try: + text = Path(wf).read_text(errors='replace') + except OSError: + continue + if 'pypa/gh-action-pypi-publish' in text: + publish_files.append(wf) + if TOKEN_INPUT_RE.search(text): + bad_token_files.append(wf) + if not ID_TOKEN_WRITE_RE.search(text): + missing_id_token_files.append(wf) + if TWINE_RE.search(text): + twine_files.append(wf) + + if not publish_files and not twine_files: + emit_check( + CHECK_ID, + 'na', + 'No PyPI publish workflow detected (no pypa/gh-action-pypi-publish or twine upload).', + ) + return EXIT_NA + + evidence = { + 'publish_workflows': publish_files, + 'twine_workflows': twine_files, + 'missing_id_token': missing_id_token_files, + 'token_inputs': bad_token_files, + } + + problems: list[str] = [] + if twine_files: + problems.append('twine upload detected (long-lived API token path)') + if bad_token_files: + problems.append('pypa/gh-action-pypi-publish invoked with password/username input') + if missing_id_token_files: + problems.append('publish workflow missing id-token: write permission') + + if not problems: + emit_check( + CHECK_ID, + 'pass', + ( + 'PyPI publishing uses Trusted Publishing (OIDC; id-token: write + ' + 'pypa/gh-action-pypi-publish, no token input).' + ), + evidence, + ) + return EXIT_PASS + + joined = '; '.join(problems) + emit_check( + CHECK_ID, + 'fail', + f'PyPI publishing is not fully on Trusted Publishing: {joined}.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Convert the publish workflow to Trusted Publishing: drop ' + 'password/username inputs, add permissions: { id-token: write } ' + 'at the job level, configure the PyPI project/environment as a ' + 'Trusted Publisher, and revoke any leftover API tokens. ' + 'Templates (fill in REPLACE_WITH_* markers before use): ' + 'personal|canonical → assets/trusted-publishing.yaml.template ' + '(inline CycloneDX SBOM + dual attestation); ' + 'product → assets/trusted-publishing-product.yaml.template + ' + 'assets/sbom-secscan.yaml.template + ' + 'assets/sbomber-manifest-{sdist,wheel}.yaml.template. ' + 'Before committing, modernise pinned action versions: for every ' + 'third-party action, look up the latest release on GitHub, pin ' + 'it by commit SHA, and update the `# vX.Y.Z` comment. Environment ' + 'name is `publish-pypi` (fleet convention). The result must pass ' + 'zizmor with no findings.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/uv_exclude_newer.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/uv_exclude_newer.py new file mode 100755 index 0000000..d5d6ecb --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/uv_exclude_newer.py @@ -0,0 +1,315 @@ +"""Check: pyproject.toml sets `[tool.uv].exclude-newer` to a rolling +quarantine of at least 7 days. + +Rationale (Canonical Security "How-To: Secure a repo" — Minimum +release age section): a package-manager-level cooldown protects +every dep-resolution path (manual `uv add`, `uv lock` regens, uvx +bootstraps, CI re-resolves) that Dependabot cooldown alone doesn't +cover — Dependabot cooldown only affects PRs Dependabot itself opens. + +uv's `exclude-newer` accepts three formats per the docs: + - RFC 3339 timestamps (absolute snapshot; e.g. 2026-01-01T00:00:00Z) + - Friendly durations (rolling window; e.g. "7 days", "1 week") + - ISO 8601 durations (rolling window; e.g. "P7D", "P30D") + +Prefer a rolling window ("7 days" / "P7D"). Absolute timestamps also +accepted but flagged in evidence: they freeze resolution to a moment +and drift silently as they age. + +Tier coverage: all tiers. +`na` when there's no `pyproject.toml`, no `[tool.uv]`, or (for tiers +where uv isn't in use) no `uv.lock`. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + EXIT_UNKNOWN, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'uv-exclude-newer' +APPLIES = 'product,canonical,personal' +MIN_DAYS = 7 + + +def parse_days(v: str): + """Classify the value: + RFC3339 timestamp: contains 'T' and ends with 'Z' or timezone offset. + ISO 8601 duration: matches /^P(?:\\d+[YMWD])+(?:T(?:\\d+[HMS])+)?$/ or PT... + Friendly duration: matches a number + unit word (hours/days/weeks/etc.) + """ + v = v.strip() + # RFC 3339 timestamp + if re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}', v): + return ('snapshot', None) + # ISO 8601 duration + m = re.match( + r'^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$', + v, + ) + if m: + y, mo, w, d, h, mi, s = (int(x) if x else 0 for x in m.groups()) + # Approx: month=30d, year=365d. + days = y * 365 + mo * 30 + w * 7 + d + h / 24 + mi / 1440 + s / 86400 + return ('iso8601', days) + # Friendly duration: parse "N unit" tokens; sum in days. + unit_days = { + 'second': 1 / 86400, + 'seconds': 1 / 86400, + 'sec': 1 / 86400, + 's': 1 / 86400, + 'minute': 1 / 1440, + 'minutes': 1 / 1440, + 'min': 1 / 1440, + 'm': 1 / 1440, + 'hour': 1 / 24, + 'hours': 1 / 24, + 'hr': 1 / 24, + 'hrs': 1 / 24, + 'h': 1 / 24, + 'day': 1, + 'days': 1, + 'd': 1, + 'week': 7, + 'weeks': 7, + 'w': 7, + 'month': 30, + 'months': 30, + 'mon': 30, + 'mo': 30, + 'year': 365, + 'years': 365, + 'yr': 365, + 'y': 365, + } + total = 0.0 + matched = False + for num, unit in re.findall(r'(\d+(?:\.\d+)?)\s*([A-Za-z]+)', v): + u = unit.lower().rstrip('.') + if u not in unit_days: + return ('unknown', None) + total += float(num) * unit_days[u] + matched = True + if matched: + return ('friendly', total) + return ('unknown', None) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + if not Path('pyproject.toml').is_file(): + emit_check(CHECK_ID, 'na', 'No pyproject.toml — not a uv project.') + return EXIT_NA + + # Parse [tool.uv] and inspect exclude-newer. Needs python3+tomllib + # (stdlib in 3.11+) — fall back to a text-only presence check when the + # parser isn't available. + try: + import tomllib + + have_parser = True + except ImportError: + have_parser = False + + if not have_parser: + # Fallback: presence check on `exclude-newer` inside `[tool.uv]`. + # This is intentionally cheap; agents on hosts without Python 3.11+ + # can still get a signal. + text = Path('pyproject.toml').read_text(errors='replace') + if re.search(r'^\s*exclude-newer\s*=', text, re.MULTILINE) and re.search( + r'^\[tool\.uv\]', text, re.MULTILINE + ): + emit_check( + CHECK_ID, + 'pass', + ( + 'exclude-newer present in pyproject.toml (value not validated — ' + 'python3+tomllib ' + 'unavailable).' + ), + {'parser': False, 'exclude_newer_present': True}, + ) + return EXIT_PASS + emit_check( + CHECK_ID, + 'fail', + 'No exclude-newer found under [tool.uv] in pyproject.toml (unvalidated fallback).', + {'parser': False, 'exclude_newer_present': False}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add `exclude-newer = "7 days"` under [tool.uv] in pyproject.toml. Prefer a ' + 'friendly-duration string (rolling window) over an RFC 3339 timestamp ' + '(absolute ' + 'snapshot). See Canonical Security "How-To: Secure a repo" — Minimum release ' + 'age.' + ), + }, + ) + return EXIT_FAIL + + try: + with open('pyproject.toml', 'rb') as f: + doc = tomllib.load(f) + except Exception as e: + emit_check( + CHECK_ID, + 'unknown', + f'Could not parse pyproject.toml: {e}', + {'parser': True}, + ) + return EXIT_UNKNOWN + + tool_uv = (doc.get('tool') or {}).get('uv') + if tool_uv is None: + # A uv.lock in the working tree means the project uses uv even + # though pyproject.toml doesn't declare [tool.uv] yet — that's + # a fail (add the section), not na. + if Path('uv.lock').is_file(): + emit_check( + CHECK_ID, + 'fail', + f'uv.lock present but pyproject.toml has no [tool.uv] section. Add [tool.uv] ' + f'with exclude-newer = "{MIN_DAYS} days".', + {'parser': True, 'tool_uv': False, 'uv_lock_present': True}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add [tool.uv] to pyproject.toml with `exclude-newer = "7 days"`. This ' + 'gives every uv resolution path (manual uv add, uv lock regens, uvx ' + 'bootstraps, CI re-resolves) a rolling 7-day quarantine on fresh ' + 'releases — ' + 'complementing the Dependabot cooldown that only covers ' + 'Dependabot-authored ' + 'PRs. See Canonical Security "How-To: Secure a repo" — Minimum release ' + 'age.' + ), + }, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, + 'na', + ( + 'pyproject.toml has no [tool.uv] section and no uv.lock — not a uv-configured ' + 'project.' + ), + {'parser': True, 'tool_uv': False, 'uv_lock_present': False}, + ) + return EXIT_NA + + if 'exclude-newer' not in tool_uv: + emit_check( + CHECK_ID, + 'fail', + f'[tool.uv] present but exclude-newer not set. Add exclude-newer = "{MIN_DAYS} ' + f'days" to give every uv resolution path (manual uv add, uv lock, uvx, CI ' + f're-resolves) a rolling {MIN_DAYS}-day quarantine on fresh releases.', + {'parser': True, 'tool_uv': True, 'exclude_newer_present': False}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add `exclude-newer = "7 days"` under [tool.uv] in pyproject.toml. Prefer a ' + 'friendly-duration string (rolling window) over an RFC 3339 timestamp ' + '(absolute ' + 'snapshot). See Canonical Security "How-To: Secure a repo" — Minimum release ' + 'age.' + ), + }, + ) + return EXIT_FAIL + + value = tool_uv['exclude-newer'] + if not isinstance(value, str): + emit_check( + CHECK_ID, + 'fail', + f'[tool.uv].exclude-newer is not a string: {type(value).__name__} {value!r}', + {'parser': True}, + {'kind': 'judgement', 'human_review': 'Set exclude-newer to a string, e.g. "7 days".'}, + ) + return EXIT_FAIL + + kind, days = parse_days(value) + + evidence = { + 'parser': True, + 'exclude_newer_kind': kind, + 'exclude_newer_value': value, + 'exclude_newer_days': days, + } + + if kind == 'snapshot': + emit_check( + CHECK_ID, + 'pass', + f'exclude-newer set to an RFC 3339 timestamp (absolute snapshot). Accepted but ' + f'note: this freezes resolution to a moment and drifts silently as time passes; ' + f'prefer a rolling friendly-duration like "{MIN_DAYS} days".', + evidence, + ) + return EXIT_PASS + + if kind in ('iso8601', 'friendly'): + days_int = int(days) if days is not None else 0 + if days_int >= MIN_DAYS: + emit_check( + CHECK_ID, + 'pass', + f"exclude-newer = '{value}' ({kind}, ≈{days} days). Rolling ≥{MIN_DAYS}-day " + f'quarantine.', + evidence, + ) + return EXIT_PASS + emit_check( + CHECK_ID, + 'fail', + f"exclude-newer = '{value}' ({kind}, ≈{days} days) is below the {MIN_DAYS}-day " + f'baseline.', + evidence, + { + 'kind': 'judgement', + 'human_review': ( + 'Widen exclude-newer to at least "7 days" to match the Charm Tech baseline ' + 'and ' + 'the existing Dependabot cooldown.' + ), + }, + ) + return EXIT_FAIL + + # unknown + emit_check( + CHECK_ID, + 'fail', + f"exclude-newer = '{value}' did not parse as an RFC 3339 timestamp, ISO 8601 duration, " + f'or friendly duration.', + evidence, + { + 'kind': 'judgement', + 'human_review': 'Set exclude-newer to a friendly duration like "7 days" per the uv ' + 'docs.', + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/vulnerability_response_plan.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/vulnerability_response_plan.py new file mode 100755 index 0000000..27c367a --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/vulnerability_response_plan.py @@ -0,0 +1,53 @@ +"""Check: Vulnerability Response plan — informational only. +Tier coverage: product, canonical. + +Lives in the SSDLC Artifacts Drive (per-product folder), not the repo. +Must be reviewed every 6 months. +""" + +from __future__ import annotations + +import sys + +from ..common import ( + EXIT_NA, + EXIT_PASS, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'vulnerability-response-plan' +APPLIES = 'product,canonical' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + emit_check( + CHECK_ID, + 'unknown', + ( + 'Cannot verify from the repo — Vulnerability Response plan lives in the SSDLC ' + 'Artifacts ' + 'Drive (per-product folder).' + ), + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'SEC0026: confirm the plan has been authored and reviewed in the last 6 months. ' + 'For ' + 'downstream/vendored components, ensure Know-Your-Upstream is recorded ' + '(security-maintained releases, notification channels, embargo posture).' + ), + }, + ) + return EXIT_PASS + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/workflow_secrets.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/workflow_secrets.py new file mode 100755 index 0000000..2b5041b --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/workflow_secrets.py @@ -0,0 +1,174 @@ +"""Check: workflow secret-handling hygiene. + +Follows the Canonical Security "Repository security" page (Secrets section) +— a repo-level secret-handling audit that flags patterns known to leak +secrets or over-scope them. Fleet audit 2026-07-02 (recorded in +roadmap/26.10/repo-setup/security-docs-gap.md rows #32-#36) turned up one +real hit across all 9 Charm Tech in-scope repos; this check turns that +audit into a reusable per-repo verification. + +Detects, in .github/workflows/*.y*ml: + * workflow-level `env:` blocks that reference `${{ secrets.* }}` + (over-scoped: every job/step in the workflow sees the secret) + * job-level `env:` blocks that reference `${{ secrets.* }}` + (over-scoped: every step in the job sees the secret; use step-level + env: instead) + * `run:` lines that `echo` / `printf` / `cat` a secret expression + (log-masking is not guaranteed for every transformation — the + reference page warns against this explicitly) + * `secrets: inherit` in reusable workflow calls (pass named secrets + instead so the callee's secret surface is auditable) + +Tier coverage: product, canonical. (Personal-tier: advisory — secret +handling still matters, but personal repos may lack even a workflow to +scan.) +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'workflow-secrets' +APPLIES = 'product,canonical,personal' + +SECRET_RE = re.compile(r'\$\{\{\s*secrets\.') +ECHO_RE = re.compile(r'^[ \t]*(-[ \t]+)?run:[ \t]*(echo|printf|cat)\b.*\$\{\{[ \t]*secrets\.') +INHERIT_RE = re.compile(r'^[ \t]*secrets:[ \t]*inherit\b') + + +def walk_env(env, scope: str, findings: list[tuple[str, str]], path_parts: list[str]) -> None: + if not isinstance(env, dict): + return + for k, v in env.items(): + if isinstance(v, str) and SECRET_RE.search(v): + findings.append((scope, '.'.join(path_parts + [str(k)]))) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + wf_dir = Path('.github/workflows') + if not wf_dir.is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/workflows/ directory.') + return EXIT_NA + + workflows = sorted([ + p for p in wf_dir.iterdir() if p.is_file() and p.suffix in ('.yml', '.yaml') + ]) + if not workflows: + emit_check(CHECK_ID, 'na', 'No workflow files under .github/workflows/.') + return EXIT_NA + + have_parser = True # PyYAML available via PEP 723 + + echo_hits: list[str] = [] + inherit_hits: list[str] = [] + for wf in workflows: + try: + text = wf.read_text(errors='replace') + except OSError: + continue + for i, line in enumerate(text.splitlines(), start=1): + if ECHO_RE.match(line): + echo_hits.append(f'{wf}:{i}:{line}') + if INHERIT_RE.match(line): + inherit_hits.append(f'{wf}:{i}:{line}') + + env_hits: list[tuple[str, str, str]] = [] + for wf in workflows: + try: + with open(wf) as f: + doc = yaml.safe_load(f) + except Exception: + continue + if not isinstance(doc, dict): + continue + findings: list[tuple[str, str]] = [] + walk_env(doc.get('env'), 'workflow', findings, []) + jobs = doc.get('jobs') or {} + if isinstance(jobs, dict): + for jname, job in jobs.items(): + if not isinstance(job, dict): + continue + walk_env(job.get('env'), 'job', findings, [f'jobs.{jname}']) + for scope, key in findings: + env_hits.append((str(wf), scope, key)) + + n_workflow = sum(1 for _, s, _ in env_hits if s == 'workflow') + n_job = sum(1 for _, s, _ in env_hits if s == 'job') + n_echo = len(echo_hits) + n_inherit = len(inherit_hits) + total = n_workflow + n_job + n_echo + n_inherit + + evidence = { + 'workflows_scanned': len(workflows), + 'parser': have_parser, + 'hits': { + 'workflow_env': n_workflow, + 'job_env': n_job, + 'echo_secret': n_echo, + 'secrets_inherit': n_inherit, + }, + } + + if total == 0: + emit_check( + CHECK_ID, + 'pass', + f'No workflow-/job-level env: secrets, echo-secret, or secrets: inherit hits across ' + f'{len(workflows)} workflow(s).', + evidence, + ) + return EXIT_PASS + + summary = ( + f'Secret-handling hits: {n_workflow} workflow-level env, {n_job} job-level env, ' + f'{n_echo} echo-secret, {n_inherit} secrets: inherit.' + ) + details = '' + if env_hits: + details += ( + 'env: scope hits:\n' + '\n'.join(f'{w}\t{s}\t{k}' for w, s, k in env_hits) + '\n' + ) + if echo_hits: + details += 'echo/printf/cat hits (file:line:content):\n' + '\n'.join(echo_hits) + '\n' + if inherit_hits: + details += 'secrets: inherit hits:\n' + '\n'.join(inherit_hits) + '\n' + + remediation = { + 'kind': 'judgement', + 'human_review': ( + 'Move secrets to step-level env: (never workflow- or job-level). Never ' + 'echo/printf/cat ' + 'a secret expression — pass via env: and reference $VAR instead. In reusable-workflow ' + 'calls, pass named secrets rather than secrets: inherit.' + ), + } + + emit_check(CHECK_ID, 'fail', summary, evidence, remediation) + if details: + sys.stderr.write(f'\n# workflow-secrets detail:\n{details}') + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/yaml_extension.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/yaml_extension.py new file mode 100755 index 0000000..b10ca88 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/yaml_extension.py @@ -0,0 +1,79 @@ +"""Check: YAML files under .github/ use the .yaml extension, not .yml. +Tier coverage: product, canonical, personal. + +Convention: Charm Tech (and the broader Canonical convention) prefers +the explicit `.yaml` spelling — matching the official YAML spec and the +pattern already used by almost every Charm Tech-authored workflow this cycle. +Mixed extensions inside one repo also defeat tooling globs that only +match one form. + +Scope: anything under .github/ — workflows, dependabot, zizmor, +issue templates, etc. Anything outside .github/ (Snapcraft snapcraft.yaml, +Rockcraft rockcraft.yaml, etc.) is out of scope; those names are fixed +by upstream tooling. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'yaml-extension' +APPLIES = 'product,canonical,personal' + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + gh = Path('.github') + if not gh.is_dir(): + emit_check(CHECK_ID, 'na', 'No .github/ directory; nothing to check.') + return EXIT_NA + + offenders = sorted(str(p) for p in gh.rglob('*.yml') if p.is_file()) + + if not offenders: + emit_check( + CHECK_ID, + 'pass', + 'All YAML files under .github/ use the .yaml extension.', + {}, + ) + return EXIT_PASS + + count = len(offenders) + joined = ', '.join(offenders) + emit_check( + CHECK_ID, + 'fail', + f'{count} file(s) under .github/ use .yml instead of .yaml: {joined}.', + {'offenders': offenders}, + { + 'kind': 'mechanical', + 'script': 'scripts/fixes/rename-yml-to-yaml.py', + 'human_review': ( + 'git mv each .yml -> .yaml under .github/. Confirm no external reference uses the ' + 'old path (workflow_call uses:, docs links, downstream consumers of action.yml).' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/zizmor_config.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/zizmor_config.py new file mode 100755 index 0000000..f0218ee --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/zizmor_config.py @@ -0,0 +1,86 @@ +"""Check: zizmor is invoked in CI. +Tier coverage: product, canonical. + +A .github/zizmor.yaml config file is no longer required — the pinning +policy has no allowlist exceptions, so zizmor's default unpinned-uses +rule is sufficient. If a config file exists it is not flagged, but +it's redundant. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from ..common import ( + EXIT_FAIL, + EXIT_NA, + EXIT_PASS, + cd_repo_root, + emit_check, + parse_tier, + tier_applies, +) + +CHECK_ID = 'zizmor-config' +APPLIES = 'product,canonical' + +PATTERN = re.compile( + r'woodruffw/zizmor|zizmor-action|uvx[ \t]+zizmor|uv[ \t]+run[ ' + r'\t].*zizmor|(^|[^a-zA-Z0-9_./-])zizmor[ \t]' +) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, 'na', f'Not applicable for tier {tier}.') + return EXIT_NA + + cd_repo_root() + + hits: list[str] = [] + wf_dir = Path('.github/workflows') + if wf_dir.is_dir(): + for p in sorted(list(wf_dir.glob('*.yml')) + list(wf_dir.glob('*.yaml'))): + try: + text = p.read_text(errors='replace') + except OSError: + continue + for line in text.splitlines(): + if PATTERN.search(line): + hits.append(str(p)) + break + + if hits: + first = hits[0] + emit_check( + CHECK_ID, + 'pass', + f'zizmor invoked in CI ({first}).', + {'workflow': first}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, + 'fail', + 'No workflow invokes zizmor.', + {}, + { + 'kind': 'judgement', + 'human_review': ( + 'Add a CI step that runs zizmor against .github/workflows/ (uvx zizmor, or via ' + 'the ' + "project's lint dependency-group). No .github/zizmor.yaml config file is " + 'required — ' + 'the default unpinned-uses rule enforces SHA-pinning without an allowlist.' + ), + }, + ) + return EXIT_FAIL + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/cli.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/cli.py new file mode 100755 index 0000000..9a86f3c --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/cli.py @@ -0,0 +1,200 @@ +"""Umbrella check runner. Dispatches every check that applies to the resolved +tier and emits a single JSON report. + +Usage: + charm-tech-baseline check [--tier=product|canonical|personal] + [--only=[,...]] + [--format=json|markdown] + charm-tech-baseline detect-tier + charm-tech-baseline fix [args...] + charm-tech-baseline list + +Check IDs are the ones in the report (`code-of-conduct`, `security-md`, ...), +not module names. +""" + +from __future__ import annotations + +import datetime +import importlib +import pkgutil +import sys +from types import ModuleType + +from . import checks as checks_pkg +from . import fixes as fixes_pkg +from . import tier as tier_mod +from .common import collecting, origin_url + + +def _modules(package: ModuleType) -> dict[str, ModuleType]: + """Import every module in a subpackage, keyed by its declared ID. + + Checks carry a CHECK_ID; fixes have no such constant, so their module + name with underscores turned back into hyphens is the name. + """ + found: dict[str, ModuleType] = {} + for info in pkgutil.iter_modules(package.__path__): + module = importlib.import_module(f'{package.__name__}.{info.name}') + found[getattr(module, 'CHECK_ID', info.name.replace('_', '-'))] = module + return found + + +def usage() -> None: + sys.stderr.write((__doc__ or '').strip() + '\n') + + +def _check(argv: list[str]) -> int: + tier_override = '' + only_filter = '' + fmt = 'json' + # Anything the runner does not recognise is passed through to the checks. + # A check ignores flags it does not know, so this only means anything + # alongside --only, where exactly one check is listening. + passthrough: list[str] = [] + + for arg in argv: + if arg.startswith('--tier='): + tier_override = arg[len('--tier=') :] + elif arg.startswith('--only='): + only_filter = arg[len('--only=') :] + elif arg.startswith('--format='): + fmt = arg[len('--format=') :] + elif arg in ('-h', '--help'): + print((__doc__ or '').strip()) + return 0 + elif arg.startswith('--'): + passthrough.append(arg) + else: + print(f'Unknown argument: {arg}', file=sys.stderr) + return 2 + + if tier_override: + tier = tier_override + tier_source = 'override' + else: + tier = tier_mod.detect() + tier_source = 'detected' + + if tier == 'unknown': + print( + 'Could not detect tier; pass --tier=product|canonical|personal', + file=sys.stderr, + ) + return 2 + + available = _modules(checks_pkg) + if only_filter: + selected = {} + for check_id in only_filter.split(','): + if check_id not in available: + print(f'Unknown check: {check_id}', file=sys.stderr) + return 2 + selected[check_id] = available[check_id] + else: + selected = dict(sorted(available.items())) + + results: list[dict] = [] + notes: list[str] = [] + saved_argv = sys.argv + for check_id, module in selected.items(): + # Each check reads its own flags off sys.argv, as it did when it was a + # standalone script. Set it explicitly rather than letting the check + # read the runner's own command line, so that a *detected* tier + # reaches the check just as an overridden one does. + sys.argv = [check_id, f'--tier={tier}', *passthrough] + # A check that raises is a bug in the check, not a finding about the + # repo, so it becomes a note rather than a fail. + try: + with collecting() as collected: + module.main() + except Exception as exc: # noqa: BLE001 + notes.append(f'check {check_id} raised {type(exc).__name__}: {exc}') + continue + finally: + sys.argv = saved_argv + if not collected: + notes.append(f'check {check_id} produced no result') + continue + results.extend(collected) + + generated_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + repo = origin_url() + + if fmt == 'json': + import json + + report = { + 'schema_version': 1, + 'repo': repo, + 'tier': tier, + 'tier_source': tier_source, + 'generated_at': generated_at, + 'checks': results, + 'notes': notes, + } + print(json.dumps(report)) + return 0 + + # Markdown summary path — human spot-checks; agents should prefer JSON. + print('# Repo-setup audit\n') + print(f'- Repo: `{repo}`') + print(f'- Tier: **{tier}** ({tier_source})') + print(f'- Generated: {generated_at}\n') + print('## Findings\n') + for r in results: + print(f'- **{r.get("status")}** (`{r.get("id")}`) — {r.get("summary")}') + if notes: + print('\n## Notes\n') + for n in notes: + print(f'- {n}') + return 0 + + +def _fix(argv: list[str]) -> int: + if not argv: + print('Usage: charm-tech-baseline fix ', file=sys.stderr) + return 2 + name, rest = argv[0], argv[1:] + available = _modules(fixes_pkg) + if name not in available: + print(f'Unknown fix: {name}', file=sys.stderr) + return 2 + # The fix scripts read sys.argv directly, as they did when each was its + # own script. + sys.argv = [f'charm-tech-baseline fix {name}', *rest] + return available[name].main() + + +def _list() -> int: + print('checks:') + for check_id in sorted(_modules(checks_pkg)): + print(f' {check_id}') + print('fixes:') + for name in sorted(_modules(fixes_pkg)): + print(f' {name}') + return 0 + + +def main() -> int: + argv = sys.argv[1:] + if not argv or argv[0] in ('-h', '--help'): + usage() + return 0 if argv else 2 + command, rest = argv[0], argv[1:] + if command == 'check': + return _check(rest) + if command == 'detect-tier': + sys.argv = ['detect-tier', *rest] + return tier_mod.main() + if command == 'fix': + return _fix(rest) + if command == 'list': + return _list() + print(f'Unknown command: {command}', file=sys.stderr) + usage() + return 2 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/common.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/common.py new file mode 100644 index 0000000..2d419b8 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/common.py @@ -0,0 +1,155 @@ +"""Shared helpers for charm-tech-baseline skill checks and fixes. + +Imported by every check / fix script. No side effects on import. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import Any + +# Templates and question batteries ship with the package rather than sitting +# beside the skill, so a `uvx --from git+...` invocation carries them too. +ASSETS = Path(__file__).parent / 'assets' + + +# Exit codes. Every check script exits with one of these. +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_NA = 2 +EXIT_UNKNOWN = 3 + + +def repo_root() -> Path: + """Return the repo root. Falls back to CWD when not inside a git tree + (the skill can be invoked against an unpacked tarball, for example).""" + try: + out = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if out: + return Path(out) + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return Path.cwd() + + +def origin_url() -> str: + """Return the origin remote URL normalised to https form, without a + trailing .git. Empty string if no origin remote.""" + try: + url = subprocess.run( + ['git', 'config', '--get', 'remote.origin.url'], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return '' + if url.startswith('git@github.com:'): + url = 'https://github.com/' + url[len('git@github.com:') :] + if url.endswith('.git'): + url = url[:-4] + return url + + +_collector: list[dict[str, Any]] | None = None + + +@contextlib.contextmanager +def collecting() -> Iterator[list[dict[str, Any]]]: + """Capture what emit_check produces instead of printing it. + + Nesting is not supported, and does not happen: only the umbrella runner + collects, and a check never runs another check. + """ + global _collector + results: list[dict[str, Any]] = [] + _collector = results + try: + yield results + finally: + _collector = None + + +def emit_check( + check_id: str, + status: str, + summary: str, + evidence: dict[str, Any] | None = None, + remediation: dict[str, Any] | None = None, +) -> None: + """Emit a single check result as a JSON object on one line to stdout. + + status is one of: pass, fail, na, unknown. + """ + payload = { + 'id': check_id, + 'status': status, + 'summary': summary, + 'evidence': evidence if evidence is not None else {}, + 'remediation': remediation, + } + if _collector is not None: + # The umbrella runner imports each check and calls its main() in + # process, so the result is handed over directly rather than being + # printed and reparsed. + _collector.append(payload) + return + # Single-line JSON, for a check invoked on its own. + sys.stdout.write(json.dumps(payload, separators=(',', ':'))) + sys.stdout.write('\n') + + +def tier_applies(check_tiers: str | Iterable[str], current_tier: str) -> bool: + """True when the current tier is in the check's applicable tiers. + + check_tiers may be a comma-separated string ("product,canonical") or + any iterable of strings. + """ + if isinstance(check_tiers, str): + tiers = {t.strip() for t in check_tiers.split(',') if t.strip()} + else: + tiers = set(check_tiers) + return current_tier in tiers + + +def parse_tier(argv: list[str] | None = None) -> str: + """Extract --tier= from argv. Returns empty string if absent. + + Unknown flags are ignored (each check only cares about --tier).""" + args = argv if argv is not None else sys.argv[1:] + for arg in args: + if arg.startswith('--tier='): + return arg[len('--tier=') :] + return '' + + +def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Convenience wrapper around subprocess.run with text=True and + capture_output=True by default. Never raises on non-zero exit — + callers should inspect .returncode.""" + kwargs.setdefault('text', True) + kwargs.setdefault('capture_output', True) + kwargs.setdefault('check', False) + return subprocess.run(cmd, **kwargs) + + +def cd_repo_root() -> Path: + """Chdir to the repo root and return it. Exits EXIT_UNKNOWN if the + root cannot be reached (matches the shell behaviour of `cd || exit 3`).""" + root = repo_root() + try: + os.chdir(root) + except OSError: + sys.exit(EXIT_UNKNOWN) + return root diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/__init__.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_agents_md.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_agents_md.py new file mode 100755 index 0000000..aa967a4 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_agents_md.py @@ -0,0 +1,44 @@ +"""Fix: copy the AGENTS.md template into the repo root. +Agent must fill in {{...}} placeholders before committing — the +template is intentionally a skeleton, not a working file. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + import os + + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('AGENTS.md').exists(): + sys.stderr.write('AGENTS.md already exists; refusing to overwrite.\n') + return 1 + + template = ASSETS / 'AGENTS.md.template' + if not template.is_file(): + sys.stderr.write('Template missing.\n') + return 3 + + shutil.copy(template, 'AGENTS.md') + sys.stdout.write( + 'Copied AGENTS.md template. Replace {{REPO_DESCRIPTION_ONE_SENTENCE}}, ' + '{{SETUP_COMMANDS}}, {{TEST_COMMANDS}}, {{LINT_COMMANDS}}, {{DEPTH_LINK_TITLE}}, ' + '{{DEPTH_LINK}} before committing.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_code_of_conduct.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_code_of_conduct.py new file mode 100755 index 0000000..87da614 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_code_of_conduct.py @@ -0,0 +1,41 @@ +"""Fix: copy the Code-of-Conduct template (link-only Ubuntu CoC) into the repo root. +Refuses to overwrite an existing file. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('CODE_OF_CONDUCT.md').exists(): + sys.stderr.write('CODE_OF_CONDUCT.md already exists; refusing to overwrite.\n') + return 1 + + template = ASSETS / 'CODE_OF_CONDUCT.md' + if not template.is_file(): + sys.stderr.write('Template missing.\n') + return 3 + + shutil.copy(template, 'CODE_OF_CONDUCT.md') + sys.stdout.write( + 'Copied CODE_OF_CONDUCT.md. No placeholders to fill in — the link-only form is complete ' + 'as-is.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_contributing.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_contributing.py new file mode 100755 index 0000000..8b07bed --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_contributing.py @@ -0,0 +1,62 @@ +"""Fix: copy the CONTRIBUTING.md template into the repo root and rewrite the +owner/repo placeholders to match origin. The template mirrors the +dominant Charm Tech pattern (substantive standalone doc with a +`# Pull requests` section). +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, origin_url, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('CONTRIBUTING.md').exists(): + sys.stderr.write('CONTRIBUTING.md already exists; refusing to overwrite.\n') + return 1 + + template = ASSETS / 'CONTRIBUTING.md.template' + if not template.is_file(): + sys.stderr.write('Template missing.\n') + return 3 + + shutil.copy(template, 'CONTRIBUTING.md') + + url = origin_url() + prefix = 'https://github.com/' + if url.startswith(prefix) and len(url) > len(prefix): + slug = url[len(prefix) :] + # Match shell: owner=${slug%%/*}, name=${slug##*/}. + owner = slug.split('/', 1)[0] + name = slug.rsplit('/', 1)[-1] + text = Path('CONTRIBUTING.md').read_text() + text = text.replace('REPLACE_WITH_OWNER', owner).replace('REPLACE_WITH_REPO', name) + Path('CONTRIBUTING.md').write_text(text) + sys.stdout.write(f'Rewrote owner/repo placeholders to {owner}/{name}.\n') + else: + sys.stderr.write( + 'Could not determine origin slug; left REPLACE_WITH_OWNER/REPO placeholders in ' + 'CONTRIBUTING.md — fix before committing.\n' + ) + + sys.stdout.write( + 'Wrote CONTRIBUTING.md. Confirm: the `# Pull requests` type list matches ' + '.github/check-conventional-pr-title.py (chore, ci, docs, feat, fix, perf, refactor, ' + 'revert, test).\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_dependabot.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_dependabot.py new file mode 100755 index 0000000..c017f5b --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_dependabot.py @@ -0,0 +1,45 @@ +"""Fix: copy the Dependabot template into .github/. +Agent must edit the package-ecosystem set to match the repo +(drop unused ecosystems, uncomment gomod / docker if applicable). +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('.github/dependabot.yml').exists() or Path('.github/dependabot.yaml').exists(): + sys.stderr.write('.github/dependabot.{yml,yaml} already exists; refusing to overwrite.\n') + return 1 + + Path('.github').mkdir(parents=True, exist_ok=True) + template = ASSETS / 'dependabot.yaml.template' + if not template.is_file(): + sys.stderr.write('Template missing.\n') + return 3 + + shutil.copy(template, '.github/dependabot.yaml') + sys.stdout.write( + 'Wrote .github/dependabot.yaml. Confirm the ecosystem set matches the repo ' + '(github-actions + uv by default; swap uv→pip or delete uv and uncomment gomod as ' + 'needed), prune the `charm-tech` group to what this repo actually depends on, and ' + 'confirm all dev tooling in use is covered by the `dev-tooling` group.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_security_md.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_security_md.py new file mode 100755 index 0000000..2683fc6 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_security_md.py @@ -0,0 +1,48 @@ +"""Fix: copy the SECURITY.md template into the repo root. +Caller is the agent, which must then: + 1. Replace placeholder fields ({{REPO}}, {{CONTACT}}, etc.). + 2. Stage and commit; do not push without user direction. + +This script never overwrites an existing SECURITY.md. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path('SECURITY.md').exists(): + sys.stderr.write( + 'SECURITY.md already exists; refusing to overwrite. Remove it first if the intent ' + 'is to replace.\n' + ) + return 1 + + template = ASSETS / 'SECURITY.md.template' + if not template.is_file(): + sys.stderr.write(f'Template missing at {template}\n') + return 3 + + shutil.copy(template, 'SECURITY.md') + sys.stdout.write( + 'Copied SECURITY.md template. Replace placeholders ({{REPO}}, {{CONTACT}}) before ' + 'committing.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_validate_pr_title.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_validate_pr_title.py new file mode 100755 index 0000000..adff610 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/add_validate_pr_title.py @@ -0,0 +1,101 @@ +"""Fix: install the operator-style Conventional Commits PR-title check. + +Two-file pattern (source: canonical/operator): + - .github/workflows/validate-pr-title.yaml — runs on pull_request + [opened, edited, synchronize], permissions: {}, no PR-title fetch from + the API; reads it from the event payload via the PR_TITLE env var. + - .github/check-conventional-pr-title.py — self-contained Python (stdlib + only). Allowed types: chore, ci, docs, feat, fix, perf, refactor, revert, + test. Scopes disallowed. + +Both files are staged from the asset templates. The Python script's _HELP_URL +placeholder is rewritten to point at this repo's CONTRIBUTING.md so the error +message links to the right place. The agent should still check the +CONTRIBUTING.md exists and documents these types. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from ..common import ASSETS, origin_url, repo_root, run + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + workflow = '.github/workflows/validate-pr-title.yaml' + script = '.github/check-conventional-pr-title.py' + + if Path(workflow).exists() or Path('.github/workflows/validate-pr-title.yml').exists(): + sys.stderr.write(f'{workflow} (or .yml variant) already exists; refusing to overwrite.\n') + return 1 + if Path(script).exists(): + sys.stderr.write(f'{script} already exists; refusing to overwrite.\n') + return 1 + + wf_template = ASSETS / 'validate-pr-title.yaml.template' + py_template = ASSETS / 'check-conventional-pr-title.py.template' + if not wf_template.is_file(): + sys.stderr.write(f'Workflow template missing: {wf_template}\n') + return 3 + if not py_template.is_file(): + sys.stderr.write(f'Python template missing: {py_template}\n') + return 3 + + Path('.github/workflows').mkdir(parents=True, exist_ok=True) + shutil.copy(wf_template, workflow) + shutil.copy(py_template, script) + + # Rewrite the help-URL placeholder to point at THIS repo. + url = origin_url() + prefix = 'https://github.com/' + if url.startswith(prefix) and len(url) > len(prefix): + slug = url[len(prefix) :] + owner = slug.split('/', 1)[0] + name = slug.rsplit('/', 1)[-1] + + r = run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']) + default_branch = r.stdout.strip() if r.returncode == 0 else '' + if default_branch.startswith('origin/'): + default_branch = default_branch[len('origin/') :] + if not default_branch: + r2 = run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']) + default_branch = r2.stdout.strip() if r2.returncode == 0 else 'main' + if not default_branch: + default_branch = 'main' + + text = Path(script).read_text() + text = text.replace('REPLACE_WITH_OWNER', owner) + text = text.replace('REPLACE_WITH_REPO', name) + text = text.replace('/blob/main/', f'/blob/{default_branch}/') + Path(script).write_text(text) + sys.stdout.write( + f'Rewrote help-URL to ' + f'https://github.com/{owner}/{name}/blob/{default_branch}/CONTRIBUTING.md#pull-requests\n' + ) + else: + sys.stderr.write( + f'Could not determine origin slug; left REPLACE_WITH_OWNER/REPO placeholders in ' + f'{script} — fix before committing.\n' + ) + + sys.stdout.write(f'Wrote {workflow} and {script}.\n') + sys.stdout.write( + 'Confirm CONTRIBUTING.md (or HACKING.md, etc.) exists in this repo and documents the ' + 'allowed Conventional-Commits types; if not, add a "Pull requests" section listing ' + 'chore/ci/docs/feat/fix/perf/refactor/revert/test before merging.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/apply_repo_settings.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/apply_repo_settings.py new file mode 100755 index 0000000..41487c5 --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/apply_repo_settings.py @@ -0,0 +1,144 @@ +"""Fix: patch live GitHub repo settings to match the baseline. + +Use this ONLY when the repo is not (and will not be) enrolled in +canonical-repo-automation (CRA). For CRA-enrolled repos, drift must be +fixed by re-applying CRA — direct API patches will be overwritten on the +next apply. The repo-settings check refuses to recommend this fix for +CRA-enrolled repos for that reason. + +What it sets: + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - secret_scanning + push protection + dependabot security updates enabled + - private vulnerability reporting enabled + - actions allowed_actions=selected (canonical-owned repos only) + +What it does NOT set: rulesets / branch protection (a separate fix — +different shape per-repo, needs the protected branch name, required checks, +and bypass policy decided per repo). + +Usage: scripts/fixes/apply-repo-settings.py [--dry-run] +The script prints each gh call before running; pass --dry-run to print only. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys + +from ..common import origin_url + +HELP_TEXT = """Fix: patch live GitHub repo settings to match the baseline. + +Use this ONLY when the repo is not (and will not be) enrolled in +canonical-repo-automation (CRA). For CRA-enrolled repos, drift must be +fixed by re-applying CRA — direct API patches will be overwritten on the +next apply. The repo-settings check refuses to recommend this fix for +CRA-enrolled repos for that reason. + +What it sets: + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - secret_scanning + push protection + dependabot security updates enabled + - private vulnerability reporting enabled + - actions allowed_actions=selected (canonical-owned repos only) + +What it does NOT set: rulesets / branch protection (a separate fix — +different shape per-repo, needs the protected branch name, required checks, +and bypass policy decided per repo). + +Usage: scripts/fixes/apply-repo-settings.py [--dry-run] +The script prints each gh call before running; pass --dry-run to print only. +""" + + +def main() -> int: + dry_run = False + for arg in sys.argv[1:]: + if arg == '--dry-run': + dry_run = True + elif arg in ('-h', '--help'): + sys.stdout.write(HELP_TEXT) + return 0 + else: + sys.stderr.write(f'Unknown argument: {arg}\n') + return 2 + + if shutil.which('gh') is None: + sys.stderr.write('gh CLI not installed.\n') + return 3 + + url = origin_url() + prefix = 'https://github.com/' + if not (url.startswith(prefix) and len(url) > len(prefix)): + sys.stderr.write(f'Could not parse owner/repo from origin URL: {url}\n') + return 3 + slug = url[len(prefix) :] + owner = slug.split('/', 1)[0] + + def run_cmd(cmd: list[str]) -> None: + sys.stdout.write('+ ' + ' '.join(cmd) + '\n') + sys.stdout.flush() + if not dry_run: + subprocess.run(cmd) + + # Merge + branch hygiene + security-and-analysis (one PATCH call). + run_cmd([ + 'gh', + 'api', + '-X', + 'PATCH', + f'repos/{slug}', + '-F', + 'allow_squash_merge=true', + '-F', + 'allow_merge_commit=false', + '-F', + 'allow_rebase_merge=false', + '-F', + 'delete_branch_on_merge=true', + '-f', + 'security_and_analysis[secret_scanning][status]=enabled', + '-f', + 'security_and_analysis[secret_scanning_push_protection][status]=enabled', + '-f', + 'security_and_analysis[dependabot_security_updates][status]=enabled', + ]) + + # Private vulnerability reporting (separate endpoint, PUT, no body). + run_cmd(['gh', 'api', '-X', 'PUT', f'repos/{slug}/private-vulnerability-reporting']) + + # Actions allowlist — canonical-owned only. Personal repos legitimately run + # `allowed_actions=all`; only flip when the repo belongs to canonical. + if owner == 'canonical': + run_cmd([ + 'gh', + 'api', + '-X', + 'PUT', + f'repos/{slug}/actions/permissions', + '-F', + 'enabled=true', + '-f', + 'allowed_actions=selected', + ]) + sys.stdout.write( + '\nNote: allowed_actions set to "selected". The selected-actions allowlist itself ' + 'is org-scoped and lives in canonical-repo-automation; this repo will inherit ' + 'whatever the org allows. If the repo needs additional vetted actions, declare them ' + 'in CRA rather than per-repo.\n' + ) + + sys.stdout.write('\nDone. Re-run scripts/check.py --only=repo-settings to confirm.\n') + if owner == 'canonical': + sys.stdout.write( + 'Reminder: this patches live settings only. For a canonical/* repo, the durable fix ' + 'is enrolment in canonical-repo-automation — these patches will drift back over ' + 'time without a CRA declaration.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/rename_yml_to_yaml.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/rename_yml_to_yaml.py new file mode 100755 index 0000000..56c1dce --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/fixes/rename_yml_to_yaml.py @@ -0,0 +1,66 @@ +"""Fix: rename every *.yml under .github/ to *.yaml, using `git mv` so +history follows. Skips files where the .yaml twin already exists (left +for manual reconciliation — likely intentional or a stale leftover). + +Does NOT update references: `workflow_call uses:` paths, README links, +downstream consumers of a composite action.yml, etc. The human-review +note on the matching check flags this; rerun the audit + grep after. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from ..common import repo_root, run + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if not Path('.github').is_dir(): + sys.stdout.write('No .github/ directory; nothing to do.\n') + return 0 + + offenders = sorted(str(p) for p in Path('.github').rglob('*.yml') if p.is_file()) + + if not offenders: + sys.stdout.write('No .yml files under .github/; nothing to do.\n') + return 0 + + in_git_repo = run(['git', 'rev-parse', '--is-inside-work-tree']).returncode == 0 + + renamed = 0 + skipped = 0 + for src in offenders: + dst = src[: -len('.yml')] + '.yaml' + if Path(dst).exists(): + sys.stderr.write(f'SKIP: {src} — {dst} already exists.\n') + skipped += 1 + continue + tracked = False + if in_git_repo: + tracked = run(['git', 'ls-files', '--error-unmatch', '--', src]).returncode == 0 + if tracked: + subprocess.run(['git', 'mv', '--', src, dst]) + else: + shutil.move(src, dst) + sys.stdout.write(f'Renamed: {src} -> {dst}\n') + renamed += 1 + + sys.stdout.write(f'\nDone: {renamed} renamed, {skipped} skipped.\n') + sys.stdout.write( + 'Reminder: grep the repo (and downstream consumers) for the old .yml paths in case any ' + 'workflow_call / README / action ref points at them.\n' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/tier.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/tier.py new file mode 100755 index 0000000..89d6c1b --- /dev/null +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/tier.py @@ -0,0 +1,108 @@ +"""Inspect the current repo's origin remote and emit one of: + + product | canonical | personal | unknown + +Detection rules (in order): + 1. URL matches https://github.com/canonical/ -> canonical or product + 2. URL matches https://github.com//: + a. If the repo is a fork of canonical/ (detected via + `gh repo view --json isFork,parent`, or an `upstream` remote + pointing at canonical/) -> canonical or product + b. Otherwise -> personal + 3. No remote / no clear org -> unknown + +The fork lookup matters because Charm Tech engineers routinely work +from a personal fork of a canonical/* repo; the baseline that applies +is the upstream repo's, not the fork owner's. + +Product-tier classification within canonical/ is driven by a small +allowlist below (Charm Tech products as of 2026-06 — operator, pebble, +jubilant, concierge, charmlibs). All other canonical/* repos are +'canonical' tier (cross-cutting requirements only). + +Override: pass an argument to force a tier (useful when auditing a +repo before transfer to the canonical org). + +detect() returns the tier; main() prints it and exits 0. +""" + +from __future__ import annotations + +import shutil +import sys + +from .common import origin_url, run + +PRODUCT_REPOS = {'operator', 'pebble', 'jubilant', 'concierge', 'charmlibs'} + + +def detect() -> str: + """Return the tier for the repo in the current working directory. + + Returns "unknown" rather than guessing when the origin remote is absent + or is not a GitHub URL. + """ + + url = origin_url() + if not url: + return 'unknown' + + prefix = 'https://github.com/' + if not url.startswith(prefix): + # Some other forwarding host; don't guess. + return 'unknown' + + path = url[len(prefix) :] + parts = path.split('/', 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + return 'unknown' + org, repo = parts + + # If origin is not under canonical/, the repo may still be a fork of + # a canonical/* repo — in which case the upstream's baseline applies. + if org != 'canonical': + parent_slug = '' + if shutil.which('gh'): + result = run([ + 'gh', + 'repo', + 'view', + f'{org}/{repo}', + '--json', + 'isFork,parent', + '--jq', + r'select(.isFork) | .parent' + r' | select(.owner.login == "canonical")' + r' | "\(.owner.login)/\(.name)"', + ]) + parent_slug = result.stdout.strip() + if not parent_slug: + upstream = run(['git', 'config', '--get', 'remote.upstream.url']).stdout.strip() + if upstream.startswith('git@github.com:'): + upstream = 'https://github.com/' + upstream[len('git@github.com:') :] + if upstream.endswith('.git'): + upstream = upstream[:-4] + if upstream.startswith(prefix): + upstream_path = upstream[len(prefix) :] + if upstream_path.startswith('canonical/'): + parent_slug = upstream_path + if parent_slug: + org = 'canonical' + repo = parent_slug[len('canonical/') :] + + if org == 'canonical': + return 'product' if repo in PRODUCT_REPOS else 'canonical' + return 'personal' + + +def main() -> int: + """Print the detected tier. An argument forces a tier instead.""" + if len(sys.argv) >= 2: + arg = sys.argv[1] + if arg in ('product', 'canonical', 'personal'): + print(arg) + return 0 + print('unknown', file=sys.stderr) + return 1 + print(detect()) + return 0 diff --git a/charm-tech-baseline/tests/checks/test_agents_md_battery.py b/charm-tech-baseline/tests/checks/test_agents_md_battery.py new file mode 100644 index 0000000..de16ea3 --- /dev/null +++ b/charm-tech-baseline/tests/checks/test_agents_md_battery.py @@ -0,0 +1,185 @@ +"""AGENTS.md question battery validation (Layer 2 seed data, checked statically).""" + +from __future__ import annotations + +import hashlib +import textwrap + +AGENTS_MD = textwrap.dedent("""\ + # AGENTS.md + + ## Test + + ```bash + go test ./internals/cli -check.f MySuite # single gocheck suite + ``` + + See [HACKING.md](HACKING.md). CI also rejects any use of `interface{}` — + write `any`. + """) + +BATTERY = textwrap.dedent("""\ + schema_version: 1 + repo: example + upstream: canonical/example + source: + agents_md_ref: chore/agents-md + agents_md_sha: deadbeef + agents_md_sha256: {digest} + seeded_from: design doc + seeded_on: 2026-08-19 + entries: + - id: single-suite + question: How do you run just MySuite? + classification: cache + source_line: "go test ./internals/cli -check.f MySuite # single gocheck suite" + answer: + grade: command + expect: go test ./internals/cli -check.f MySuite + verify: + - kind: suite_in_package + suite: MySuite + package: internals/cli + ci_verifiable: true + - id: no-empty-interface + question: What do you write instead of `interface{{}}`? + classification: override + source_line: CI also rejects any use of `interface{{}}` — write `any`. + answer: + grade: keywords + require: + - any + verify: + - kind: path_exists + path: HACKING.md + ci_verifiable: true + """) + +TREE = { + 'AGENTS.md': AGENTS_MD, + 'HACKING.md': '# Hacking\n', + 'internals/cli/suite_test.go': 'package cli\n\ntype MySuite struct{}\n', +} + + +def battery() -> str: + return BATTERY.format(digest=hashlib.sha256(AGENTS_MD.encode()).hexdigest()) + + +def run(run_check, files: dict[str, str], battery_text: str) -> dict: + return run_check( + 'agents-md-battery', + 'canonical', + {**files, 'battery.yaml': battery_text}, + ('--battery=battery.yaml',), + ) + + +def test_na_when_no_battery_for_repo(run_check): + # No --battery and no origin remote to name one: not a gap, just a repo + # that hasn't been through the Layer 2 authoring gate. + r = run_check('agents-md-battery', 'canonical', TREE) + assert r['status'] == 'na' + + +def test_pass_when_battery_matches_repo(run_check): + r = run(run_check, TREE, battery()) + assert r['status'] == 'pass' + assert r['evidence']['drifted_source_lines'] == [] + assert r['evidence']['verify_findings'] == [] + assert r['evidence']['entries_by_classification'] == {'cache': 1, 'override': 1} + + +def test_source_line_matches_across_a_wrapped_line(run_check): + # The interface{} source line wraps in AGENTS.md; whitespace is collapsed + # on both sides so a single-line entry still matches. + r = run(run_check, TREE, battery()) + assert r['status'] == 'pass' + + +def test_fail_when_source_line_no_longer_in_agents_md(run_check): + b = battery().replace('write `any`.', 'write `anything`.') + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert [d['entry'] for d in r['evidence']['drifted_source_lines']] == ['no-empty-interface'] + + +def test_fail_when_suite_no_longer_in_named_package(run_check): + # The canonical Layer 1 case, carried into the battery: pebble's + # PebbleSuite documented against a package it has moved out of. + files = {**TREE} + files.pop('internals/cli/suite_test.go') + files['internals/cli/other_test.go'] = 'package cli\n\nfunc TestOther() {}\n' + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'suite_in_package' + and f['problem'] == 'suite identifier not found anywhere in package' + for f in r['evidence']['verify_findings'] + ) + + +def test_fail_when_referenced_path_gone(run_check): + files = {k: v for k, v in TREE.items() if k != 'HACKING.md'} + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'path_exists' and f['path'] == 'HACKING.md' + for f in r['evidence']['verify_findings'] + ) + + +def test_fail_when_text_in_file_pattern_missing(run_check): + b = battery().replace( + ' - kind: path_exists\n path: HACKING.md\n', + ' - kind: text_in_file\n file: HACKING.md\n pattern: no such text\n', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any( + f['kind'] == 'text_in_file' and f['problem'] == 'pattern not found in file' + for f in r['evidence']['verify_findings'] + ) + + +def test_verify_none_is_reported_not_failed(run_check): + b = battery().replace( + ' - kind: path_exists\n path: HACKING.md\n', + ' - kind: none\n reason: lives in GitHub settings, not the tree\n', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'pass' + assert r['evidence']['unanchored_entries'] == ['no-empty-interface'] + + +def test_schema_finding_when_ungated_entry_is_not_ci_verifiable(run_check): + b = battery().replace( + ' ci_verifiable: true\n - id: no-empty-interface', + ' ci_verifiable: false\n - id: no-empty-interface', + ) + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any('gated_by' in f for f in r['evidence']['schema_findings']) + + +def test_schema_finding_on_unknown_answer_grade(run_check): + b = battery().replace('grade: command', 'grade: vibes') + r = run(run_check, TREE, b) + assert r['status'] == 'fail' + assert any('answer.grade' in f for f in r['evidence']['schema_findings']) + + +def test_fail_when_agents_md_absent_but_battery_present(run_check): + files = {k: v for k, v in TREE.items() if k != 'AGENTS.md'} + r = run(run_check, files, battery()) + assert r['status'] == 'fail' + assert 'no AGENTS.md' in r['summary'] + + +def test_digest_change_is_evidence_not_failure(run_check): + # A changed AGENTS.md is a Layer 2 re-test trigger, not a defect — the file + # may have improved. Surfaced as evidence, never as a fail on its own. + files = {**TREE, 'AGENTS.md': AGENTS_MD + '\nAn extra, harmless sentence.\n'} + r = run(run_check, files, battery()) + assert r['status'] == 'pass' + assert r['evidence']['agents_md_changed_since_seeding'] is True diff --git a/charm-tech-baseline/tests/checks/test_agents_md_content.py b/charm-tech-baseline/tests/checks/test_agents_md_content.py new file mode 100644 index 0000000..0e2208a --- /dev/null +++ b/charm-tech-baseline/tests/checks/test_agents_md_content.py @@ -0,0 +1,200 @@ +"""AGENTS.md content check: the five Layer 1 staleness checks.""" + +from __future__ import annotations + +import textwrap + +CLEAN = textwrap.dedent("""\ + # AGENTS.md + + See [HACKING.md](HACKING.md) for details. Tests use `gopkg.in/check.v1`. + + ## Build and test + + ```bash + true --check # lint gate + false --lxd deploy # deploys via LXD, needs juju + ``` + """) + + +def test_na_when_agents_md_missing(run_check): + r = run_check('agents-md-content', 'canonical', {}) + assert r['status'] == 'na' + + +def test_pass_when_content_clean(run_check): + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + assert r['status'] == 'pass' + assert r['evidence']['runnable_failed'] == [] + assert r['evidence']['missing_paths'] == [] + + +def test_module_path_not_flagged_as_missing_file(run_check): + # gopkg.in/check.v1 is a Go module path, not a local file — must not be + # reported missing just because it contains a '/'. + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + assert 'gopkg.in/check.v1' not in r['evidence']['missing_paths'] + + +def test_environment_gated_command_not_executed(run_check): + # `false` would fail if run; it must be classified environment-gated + # (lxd/juju) and skipped, not executed — proven indirectly by the + # overall check still passing (see test_pass_when_content_clean). + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': CLEAN, + 'HACKING.md': '# Hacking\n', + }, + ) + gated_commands = [g['command'] for g in r['evidence']['environment_gated']] + assert any(c.startswith('false') for c in gated_commands) + assert not any(rr['command'].startswith('false') for rr in r['evidence']['runnable_failed']) + + +def test_fail_when_referenced_path_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + See [BOGUS.md](BOGUS.md) for details. + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert 'BOGUS.md' in r['evidence']['missing_paths'] + + +def test_fail_when_command_tool_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + definitelynotarealbinary123 --check # lint + ``` + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert any(m['tool'] == 'definitelynotarealbinary123' for m in r['evidence']['missing_tools']) + + +def test_fail_when_runnable_command_fails(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + false --check # lint gate + ``` + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert r['evidence']['runnable_failed'] + assert r['evidence']['runnable_failed'][0]['command'].startswith('false') + + +def test_suite_not_found_in_package_flags_finding(run_check): + # The canonical case: a gocheck suite documented against a package that + # no longer contains it (pebble's PebbleSuite/cmd-pebble staleness). + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + 'internals/cli/other_test.go': 'package cli\n\nfunc TestSomethingElse() {}\n', + }, + ) + findings = r['evidence']['suite_findings'] + assert any( + f['suite'] == 'MySuite' + and f['problem'] == 'suite identifier not found anywhere in package' + for f in findings + ) + + +def test_suite_found_in_package_no_finding(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + 'internals/cli/suite_test.go': 'package cli\n\ntype MySuite struct{}\n', + }, + ) + assert r['evidence']['suite_findings'] == [] + + +def test_scope_lint_flags_harness_content(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + Some guidance for agents. + + Co-Authored-By: Claude + """) + r = run_check('agents-md-content', 'canonical', {'AGENTS.md': md}) + assert r['status'] == 'fail' + assert r['evidence']['scope_lint_findings'] + + +def test_version_pin_drift_flagged(run_check): + md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + '.github/workflows/lint.yaml': ( + 'steps:\n - run: go install widget/cmd/widget@v2.0.0\n' + ), + }, + ) + assert r['status'] == 'fail' + assert r['evidence']['version_drift'] + assert r['evidence']['version_drift'][0]['tool'] == 'widget' + assert r['evidence']['version_drift'][0]['doc_version'] == 'v1.0.0' + assert r['evidence']['version_drift'][0]['ci_versions'] == ['v2.0.0'] + + +def test_version_pin_matches_ci(run_check): + md = '# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n' + r = run_check( + 'agents-md-content', + 'canonical', + { + 'AGENTS.md': md, + '.github/workflows/lint.yaml': ( + 'steps:\n - run: go install widget/cmd/widget@v1.0.0\n' + ), + }, + ) + assert r['status'] == 'pass' + assert r['evidence']['version_drift'] == [] + assert r['evidence']['version_pins_checked'][0]['tool'] == 'widget' diff --git a/charm-tech-baseline/tests/checks/test_dependabot.py b/charm-tech-baseline/tests/checks/test_dependabot.py new file mode 100644 index 0000000..a25bb62 --- /dev/null +++ b/charm-tech-baseline/tests/checks/test_dependabot.py @@ -0,0 +1,47 @@ +"""Dependabot check: presence, ecosystems, and cooldown >= 7 days.""" + +from __future__ import annotations + +import textwrap + +PASSING = textwrap.dedent("""\ + version: 2 + updates: + - package-ecosystem: pip + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 7 + - package-ecosystem: github-actions + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 7 + """) + +SHORT_COOLDOWN = textwrap.dedent("""\ + version: 2 + updates: + - package-ecosystem: pip + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 3 + """) + + +def test_pass_when_ecosystems_have_cooldown(run_check): + r = run_check('dependabot', 'canonical', {'.github/dependabot.yaml': PASSING}) + assert r['status'] == 'pass' + assert r['evidence']['ecosystems'] == 2 + + +def test_fail_when_cooldown_below_baseline(run_check): + r = run_check('dependabot', 'canonical', {'.github/dependabot.yaml': SHORT_COOLDOWN}) + assert r['status'] == 'fail' + assert 'cooldown' in r['summary'].lower() + + +def test_fail_when_config_missing(run_check): + r = run_check('dependabot', 'canonical', {}) + assert r['status'] == 'fail' diff --git a/charm-tech-baseline/tests/conftest.py b/charm-tech-baseline/tests/conftest.py new file mode 100644 index 0000000..4360e69 --- /dev/null +++ b/charm-tech-baseline/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared helpers for the charm-tech-baseline check tests. + +The tests are functional: each writes a small tree into a tmp dir and runs +the real check through the installed console script, in a subprocess. No +mocking, and no importing the check into the test process, so a check that +reads the environment or shells out is exercised the way it really runs. +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest + +CLI = 'charm-tech-baseline' + + +@pytest.fixture +def run_check(tmp_path, monkeypatch): + """Return ``run(check_name, tier, files)`` -> parsed JSON dict. + + ``files`` is a mapping of repo-relative path -> file contents. Parent + directories are created as needed. ``args`` are extra CLI flags passed + after ``--only``. The check runs with cwd = tmp_path. + """ + + def _run(name: str, tier: str, files: dict[str, str], args: tuple[str, ...] = ()) -> dict: + for rel, body in files.items(): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body) + monkeypatch.chdir(tmp_path) + proc = subprocess.run( + [CLI, 'check', f'--tier={tier}', f'--only={name}', '--format=json', *args], + capture_output=True, + text=True, + check=False, + ) + assert proc.stdout, f'{name} produced no stdout (stderr: {proc.stderr!r})' + report = json.loads(proc.stdout) + assert report['checks'], f'{name} produced no result (notes: {report["notes"]})' + return report['checks'][0] + + return _run diff --git a/charm-tech-baseline/tests/test_check_runner.py b/charm-tech-baseline/tests/test_check_runner.py new file mode 100644 index 0000000..33e772e --- /dev/null +++ b/charm-tech-baseline/tests/test_check_runner.py @@ -0,0 +1,26 @@ +"""The runner: one smoke test that --only dispatches and shapes a report.""" + +from __future__ import annotations + +import json +import subprocess + + +def test_only_dispatches_selected_check(tmp_path): + (tmp_path / '.github').mkdir() + (tmp_path / '.github' / 'dependabot.yaml').write_text( + 'version: 2\nupdates:\n - package-ecosystem: pip\n directory: /\n' + ' schedule: {interval: weekly}\n cooldown: {default-days: 7}\n' + ) + proc = subprocess.run( + ['charm-tech-baseline', 'check', '--tier=canonical', '--only=dependabot'], + capture_output=True, + text=True, + check=True, + cwd=tmp_path, + ) + report = json.loads(proc.stdout) + assert report['tier'] == 'canonical' + assert report['tier_source'] == 'override' + assert [c['id'] for c in report['checks']] == ['dependabot'] + assert report['checks'][0]['status'] == 'pass' diff --git a/charm-tech-baseline/tests/test_detect_tier.py b/charm-tech-baseline/tests/test_detect_tier.py new file mode 100644 index 0000000..d04ed81 --- /dev/null +++ b/charm-tech-baseline/tests/test_detect_tier.py @@ -0,0 +1,45 @@ +"""detect-tier: override arg is pure; git-driven paths use a real init.""" + +from __future__ import annotations + +import subprocess + + +def _run(*args, cwd=None): + return subprocess.run( + ['charm-tech-baseline', 'detect-tier', *args], + capture_output=True, + text=True, + check=False, + cwd=cwd, + ) + + +def test_override_product(): + assert _run('product').stdout.strip() == 'product' + + +def test_override_rejects_garbage(): + proc = _run('something-else') + assert proc.returncode != 0 + assert proc.stderr.strip() == 'unknown' + + +def test_canonical_product_repo_from_origin(tmp_path): + subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) + subprocess.run( + ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/operator'], + cwd=tmp_path, + check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == 'product' + + +def test_canonical_non_product_repo(tmp_path): + subprocess.run(['git', 'init', '-q'], cwd=tmp_path, check=True) + subprocess.run( + ['git', 'remote', 'add', 'origin', 'https://github.com/canonical/lxd'], + cwd=tmp_path, + check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == 'canonical' diff --git a/charm-tech-baseline/uv.lock b/charm-tech-baseline/uv.lock new file mode 100644 index 0000000..fc00419 --- /dev/null +++ b/charm-tech-baseline/uv.lock @@ -0,0 +1,224 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "charm-tech-code-charm-tech-baseline" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +unit = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml" }] + +[package.metadata.requires-dev] +unit = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From dd19fe83cdf6e2f538b8450a11c0a09df8fd5aa3 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 28 Aug 2026 17:49:06 +1200 Subject: [PATCH 2/5] fix: use actions/attest directly rather than the wrapper actions As of v4, actions/attest-build-provenance is documented as "simply a wrapper on top of actions/attest", and upstream says new implementations should use actions/attest instead. Point both trusted-publishing templates at it, and rewrite the comment in the canonical template that explained the two attestation calls in terms of the wrapper's missing sbom-path input. The attest-build-provenance check matched on the wrapper's name alone, so it would have failed a workflow that took upstream's advice. It now accepts either action, comparing the action name exactly rather than by substring so that actions/attest-sbom does not pass a provenance check on a prefix match. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016fRGz2wZnuTK1kXNnu7PYx --- .../trusted-publishing-product.yaml.template | 2 +- .../assets/trusted-publishing.yaml.template | 11 ++++++----- .../checks/attest_build_provenance.py | 15 +++++++++++---- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template index 4fe1577..c80cd90 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template @@ -57,7 +57,7 @@ jobs: run: uv build - name: Attest build provenance (SLSA) - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-path: 'dist/*' diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template index e15b447..a5d7dc9 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template @@ -23,10 +23,11 @@ # - enable-cache: false on setup-uv (avoids cache-poisoning of the release). # # Two separate attestations are produced: SLSA provenance (how it was built) -# via attest-build-provenance, and a CycloneDX SBOM predicate (what's inside) -# via attest. attest-build-provenance does not accept sbom-path, so the -# SBOM predicate needs its own call. (attest-sbom is deprecated; actions/attest -# is the direct replacement and accepts the same subject-path/sbom-path inputs.) +# and a CycloneDX SBOM predicate (what's inside), both via actions/attest: +# with no sbom-path it defaults to provenance, with sbom-path it attests the +# SBOM, and the two predicates cannot come from one call. (attest-build-provenance +# and attest-sbom are both just wrappers over actions/attest now; new workflows +# should call it directly.) name: Publish @@ -67,7 +68,7 @@ jobs: --output-file sbom.cdx.json - name: Attest build provenance (SLSA) - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 with: subject-path: 'dist/*' diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py index dc63de7..fd8b1ae 100755 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py @@ -1,4 +1,4 @@ -"""Check: actions/attest-build-provenance present in release / publish workflows, +"""Check: a build-provenance attestation is present in release / publish workflows, with a `subject-path:` input, AND ordered to run *before* the publish step. Tier coverage: product, canonical. @@ -30,7 +30,11 @@ CHECK_ID = 'attest-build-provenance' APPLIES = 'product,canonical' -ATTEST = 'actions/attest-build-provenance' +# actions/attest is the current action; attest-build-provenance is a thin +# wrapper over it, still accepted for repos that have not migrated yet. +# attest-sbom is deliberately absent: it attests an SBOM, not provenance, and +# the attest-sbom-deprecated check covers it. +ATTEST_ACTIONS = ('actions/attest', 'actions/attest-build-provenance') PUBLISH_ACTIONS = ( 'pypa/gh-action-pypi-publish', 'snapcore/action-publish', @@ -71,7 +75,10 @@ def is_attest_step(step) -> bool: if not isinstance(step, dict): return False uses = step.get('uses') or '' - return ATTEST in uses + # Compare the action name exactly: 'actions/attest' is a prefix of both + # 'actions/attest-build-provenance' and 'actions/attest-sbom', so a + # substring test would quietly accept the SBOM action here. + return uses.split('@', 1)[0].strip() in ATTEST_ACTIONS def needs_of(job) -> list[str]: @@ -241,7 +248,7 @@ def main() -> int: { 'kind': 'judgement', 'human_review': ( - 'Wire actions/attest-build-provenance@ BEFORE the publish step in the same ' + 'Wire actions/attest@ BEFORE the publish step in the same ' 'job, or in an upstream job in the publish jobs `needs:` chain. Set ' '`with.subject-path` to the published artefact glob (e.g. dist/*). Concierge skip ' 'applies until goreleaser build/publish split lands.' From 48642a28363640b709173817a6c00b4d3fa3f531 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 28 Aug 2026 18:24:13 +1200 Subject: [PATCH 3/5] feat: publish to TestPyPI from the release workflow itself A separate test-publish workflow drifts from the real one, so the rehearsal stops exercising the steps most likely to break. Both trusted-publishing templates now take a tag push and a manual dispatch, with the trigger selecting the environment, the repository URL and which of the two mutually exclusive version steps runs. A reusable workflow would be tidier, but PyPI validates the job_workflow_ref OIDC claim, so a reusable workflow cannot be the workflow in a trusted publisher (warehouse#11096); the header comments say so, to stop someone refactoring into that shape later. Both templates also gain the tag/version check that charmlint grew, since a hand-maintained version in pyproject.toml is otherwise unchecked. _targets_test_pypi matched TestPyPI hosts anywhere in the repository URL, so the merged publish step, whose URL expression names both hosts, read as TestPyPI-only. That made is_publish_step reject it, and the check then reported 'na' rather than looking for an attestation - a workflow taking the shape we now recommend would silently stop being checked. A step that names a real PyPI host as well as a test one is no longer treated as TestPyPI-only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016fRGz2wZnuTK1kXNnu7PYx --- .../trusted-publishing-product.yaml.template | 48 +++++++++++++++++-- .../assets/trusted-publishing.yaml.template | 48 +++++++++++++++++-- .../checks/attest_build_provenance.py | 24 ++++++---- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template index c80cd90..3d5731a 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template @@ -6,6 +6,10 @@ # - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) # - Tag pattern under `on.push.tags` # +# Register the trusted publisher twice, once on PyPI and once on TestPyPI, +# both against THIS workflow's filename, with environments publish-pypi and +# publish-testpypi respectively. +# # Before committing: modernise every SHA-pinned action below. For each `uses:` # line, look up the latest release on GitHub, replace the SHA with the current # commit SHA of that release, and update the trailing `# vX.Y.Z` version @@ -24,22 +28,33 @@ # - id-token: write + attestations: write scoped to the publish job only. # - persist-credentials: false on every checkout. # - enable-cache: false on setup-uv. +# +# A tag push publishes to PyPI and a manual run publishes to TestPyPI, from one +# workflow so that a rehearsal exercises the same steps as a real release. Do +# not split this into a reusable workflow called by two thin wrappers: PyPI +# validates the job_workflow_ref OIDC claim, so a reusable workflow cannot be +# the workflow in a trusted publisher (warehouse#11096). +# +# The manual run needs the .dev suffix because TestPyPI refuses a version it +# has already seen, so without it, rehearsing the same release twice burns a +# version number. name: Publish on: push: tags: ['v*'] + workflow_dispatch: permissions: {} jobs: publish: - name: Build and publish to PyPI (Trusted Publishing) + name: Build and publish to ${{ github.event_name == 'push' && 'PyPI' || 'TestPyPI' }} (Trusted Publishing) runs-on: ubuntu-latest environment: - name: publish-pypi - url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + name: ${{ github.event_name == 'push' && 'publish-pypi' || 'publish-testpypi' }} + url: ${{ github.event_name == 'push' && 'https://pypi.org/p/REPLACE_WITH_PROJECT_NAME' || 'https://test.pypi.org/p/REPLACE_WITH_PROJECT_NAME' }} permissions: id-token: write # OIDC to PyPI + sigstore for attestations. attestations: write # Write build-provenance predicate. @@ -49,6 +64,29 @@ jobs: with: persist-credentials: false + - name: Check the tag matches the project version + if: github.event_name == 'push' + env: + TAG: ${{ github.ref_name }} + run: | + version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + if [ "$TAG" != "v$version" ]; then + echo "::error::Tag $TAG does not match pyproject.toml version $version (expected v$version)." + exit 1 + fi + + - name: Give the build a .dev suffix + if: github.event_name == 'workflow_dispatch' + env: + RUN_NUMBER: ${{ github.run_number }} + run: | + sed -i -E "0,/^version = \"([^\"]+)\"/s//version = \"\\1.dev${RUN_NUMBER}\"/" pyproject.toml + version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + case "$version" in + *.dev"$RUN_NUMBER") echo "Building $version" ;; + *) echo "::error::Failed to add a .dev suffix; version is $version." ; exit 1 ;; + esac + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: false @@ -61,8 +99,10 @@ jobs: with: subject-path: 'dist/*' - - name: Publish to PyPI + - name: Publish uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + repository-url: ${{ github.event_name == 'push' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }} secscan: # SBOM + Canonical secscan (product-tier requirement). diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template index a5d7dc9..cf1bd4c 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template @@ -6,6 +6,10 @@ # - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) # - Tag pattern under `on.push.tags` (default: v*; operator uses [1-3].*) # +# Register the trusted publisher twice, once on PyPI and once on TestPyPI, +# both against THIS workflow's filename, with environments publish-pypi and +# publish-testpypi respectively. +# # Before committing: modernise every SHA-pinned action below. For each `uses:` # line, look up the latest release on GitHub, replace the SHA with the current # commit SHA of that release, and update the trailing `# vX.Y.Z` version @@ -28,22 +32,33 @@ # SBOM, and the two predicates cannot come from one call. (attest-build-provenance # and attest-sbom are both just wrappers over actions/attest now; new workflows # should call it directly.) +# +# A tag push publishes to PyPI and a manual run publishes to TestPyPI, from one +# workflow so that a rehearsal exercises the same steps as a real release. Do +# not split this into a reusable workflow called by two thin wrappers: PyPI +# validates the job_workflow_ref OIDC claim, so a reusable workflow cannot be +# the workflow in a trusted publisher (warehouse#11096). +# +# The manual run needs the .dev suffix because TestPyPI refuses a version it +# has already seen, so without it, rehearsing the same release twice burns a +# version number. name: Publish on: push: tags: ['v*'] + workflow_dispatch: permissions: {} jobs: publish: - name: Build and publish to PyPI (Trusted Publishing) + name: Build and publish to ${{ github.event_name == 'push' && 'PyPI' || 'TestPyPI' }} (Trusted Publishing) runs-on: ubuntu-latest environment: - name: publish-pypi - url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + name: ${{ github.event_name == 'push' && 'publish-pypi' || 'publish-testpypi' }} + url: ${{ github.event_name == 'push' && 'https://pypi.org/p/REPLACE_WITH_PROJECT_NAME' || 'https://test.pypi.org/p/REPLACE_WITH_PROJECT_NAME' }} permissions: id-token: write # OIDC to PyPI + sigstore for attestations. attestations: write # Write build-provenance + SBOM predicates. @@ -53,6 +68,29 @@ jobs: with: persist-credentials: false + - name: Check the tag matches the project version + if: github.event_name == 'push' + env: + TAG: ${{ github.ref_name }} + run: | + version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + if [ "$TAG" != "v$version" ]; then + echo "::error::Tag $TAG does not match pyproject.toml version $version (expected v$version)." + exit 1 + fi + + - name: Give the build a .dev suffix + if: github.event_name == 'workflow_dispatch' + env: + RUN_NUMBER: ${{ github.run_number }} + run: | + sed -i -E "0,/^version = \"([^\"]+)\"/s//version = \"\\1.dev${RUN_NUMBER}\"/" pyproject.toml + version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + case "$version" in + *.dev"$RUN_NUMBER") echo "Building $version" ;; + *) echo "::error::Failed to add a .dev suffix; version is $version." ; exit 1 ;; + esac + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: false @@ -85,5 +123,7 @@ jobs: path: sbom.cdx.json if-no-files-found: error - - name: Publish to PyPI + - name: Publish uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + repository-url: ${{ github.event_name == 'push' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }} diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py index fd8b1ae..2f72b40 100755 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/checks/attest_build_provenance.py @@ -42,19 +42,27 @@ 'softprops/action-gh-release', ) TEST_PYPI_HOSTS = ('test.pypi.org', 'testpypi.org') +# Deliberately not a bare 'pypi.org': that is a substring of 'test.pypi.org', +# as is 'pypi.org/legacy' of 'test.pypi.org/legacy'. +PYPI_HOSTS = ('upload.pypi.org',) def _targets_test_pypi(step: dict) -> bool: with_block = step.get('with') or {} - url = with_block.get('repository-url') or with_block.get('repository_url') or '' - if any(h in url for h in TEST_PYPI_HOSTS): - return True - run_str = step.get('run') or '' - if any(h in run_str for h in TEST_PYPI_HOSTS): - return True env = step.get('env') or {} - url2 = env.get('TWINE_REPOSITORY_URL') or env.get('TWINE_REPOSITORY') or '' - return any(h in url2 for h in TEST_PYPI_HOSTS) + candidates = ( + with_block.get('repository-url') or with_block.get('repository_url') or '', + step.get('run') or '', + env.get('TWINE_REPOSITORY_URL') or env.get('TWINE_REPOSITORY') or '', + ) + text = ' '.join(candidates) + if not any(h in text for h in TEST_PYPI_HOSTS): + return False + # One step can name both hosts and choose between them at runtime from the + # trigger, which is the shape the trusted-publishing templates use so that a + # manual TestPyPI run rehearses the real release. That step does publish to + # PyPI, so it is not a TestPyPI-only step and still needs an attestation. + return not any(h in text for h in PYPI_HOSTS) def is_publish_step(step) -> bool: From 498a0df9fe66b3c76b1f7c387e05308d22c18968 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 28 Aug 2026 18:30:49 +1200 Subject: [PATCH 4/5] docs: say why only the SBOM is uploaded as an artifact The reasoning was only in a review thread, which the template does not carry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016fRGz2wZnuTK1kXNnu7PYx --- .../assets/trusted-publishing.yaml.template | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template index cf1bd4c..4e367ab 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template @@ -116,6 +116,10 @@ jobs: subject-path: 'dist/*' sbom-path: sbom.cdx.json + # Only the SBOM is uploaded: the sdist and wheel go to PyPI moments later + # and stay there, whereas this is the only convenient copy of the SBOM + # (the other being inside the attestation predicate). + - name: Upload SBOM artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: From 2e08821f31e791e2271c2a29865f75e1010f2ba8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 1 Sep 2026 09:40:24 +1200 Subject: [PATCH 5/5] chore: carry the charmlint publish-workflow review back into the templates The review on canonical/charmlint#199 landed four changes to the workflow that this template is the source for, so bring them back here: * Comment the `environment:` name and url, explaining that the name must match the environment registered with the trusted publisher on each index while the url is only the deployment link in the UI. * Convert the tag/version check and the .dev suffix step from shell to `shell: python`, dropping the `python3 -c` round trip and the sed escaping. * Attach the SBOM-upload comment to the step it explains. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015exA46csF9tmRnQ8mLrzvA --- .../trusted-publishing-product.yaml.template | 38 ++++++++++++------ .../assets/trusted-publishing.yaml.template | 39 ++++++++++++------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template index 3d5731a..e2c57e2 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing-product.yaml.template @@ -53,10 +53,13 @@ jobs: name: Build and publish to ${{ github.event_name == 'push' && 'PyPI' || 'TestPyPI' }} (Trusted Publishing) runs-on: ubuntu-latest environment: + # The name must match the environment registered with the trusted + # publisher on PyPI (and separately on TestPyPI). name: ${{ github.event_name == 'push' && 'publish-pypi' || 'publish-testpypi' }} + # Cosmetic: the link shown against the deployment in the GitHub UI. url: ${{ github.event_name == 'push' && 'https://pypi.org/p/REPLACE_WITH_PROJECT_NAME' || 'https://test.pypi.org/p/REPLACE_WITH_PROJECT_NAME' }} permissions: - id-token: write # OIDC to PyPI + sigstore for attestations. + id-token: write # OIDC to PyPI + sigstore for attestations. attestations: write # Write build-provenance predicate. contents: read steps: @@ -68,24 +71,35 @@ jobs: if: github.event_name == 'push' env: TAG: ${{ github.ref_name }} + shell: python run: | - version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - if [ "$TAG" != "v$version" ]; then - echo "::error::Tag $TAG does not match pyproject.toml version $version (expected v$version)." - exit 1 - fi + import os, pathlib, sys, tomllib + tag = os.environ["TAG"] + version = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"] + if tag != f"v{version}": + print(f"::error::Tag {tag} does not match pyproject.toml version {version} (expected v{version}).") + sys.exit(1) - name: Give the build a .dev suffix if: github.event_name == 'workflow_dispatch' env: RUN_NUMBER: ${{ github.run_number }} + shell: python run: | - sed -i -E "0,/^version = \"([^\"]+)\"/s//version = \"\\1.dev${RUN_NUMBER}\"/" pyproject.toml - version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - case "$version" in - *.dev"$RUN_NUMBER") echo "Building $version" ;; - *) echo "::error::Failed to add a .dev suffix; version is $version." ; exit 1 ;; - esac + import os, pathlib, re, sys, tomllib + run_number = os.environ["RUN_NUMBER"] + path = pathlib.Path("pyproject.toml") + # Write dev version. + path.write_text(re.sub( + r'^version = "([^"]+)"', rf'version = "\1.dev{run_number}"', path.read_text(), + count=1, flags=re.MULTILINE, + )) + # Ensure we wrote the dev version correctly. + version = tomllib.loads(path.read_text())["project"]["version"] + if not version.endswith(f".dev{run_number}"): + print(f"::error::Failed to add a .dev suffix; version is {version}.") + sys.exit(1) + print(f"Building {version}") - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: diff --git a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template index 4e367ab..9a6f570 100644 --- a/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template +++ b/charm-tech-baseline/src/charm_tech_code/charm_tech_baseline/assets/trusted-publishing.yaml.template @@ -57,10 +57,13 @@ jobs: name: Build and publish to ${{ github.event_name == 'push' && 'PyPI' || 'TestPyPI' }} (Trusted Publishing) runs-on: ubuntu-latest environment: + # The name must match the environment registered with the trusted + # publisher on PyPI (and separately on TestPyPI). name: ${{ github.event_name == 'push' && 'publish-pypi' || 'publish-testpypi' }} + # Cosmetic: the link shown against the deployment in the GitHub UI. url: ${{ github.event_name == 'push' && 'https://pypi.org/p/REPLACE_WITH_PROJECT_NAME' || 'https://test.pypi.org/p/REPLACE_WITH_PROJECT_NAME' }} permissions: - id-token: write # OIDC to PyPI + sigstore for attestations. + id-token: write # OIDC to PyPI + sigstore for attestations. attestations: write # Write build-provenance + SBOM predicates. contents: read steps: @@ -72,24 +75,35 @@ jobs: if: github.event_name == 'push' env: TAG: ${{ github.ref_name }} + shell: python run: | - version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - if [ "$TAG" != "v$version" ]; then - echo "::error::Tag $TAG does not match pyproject.toml version $version (expected v$version)." - exit 1 - fi + import os, pathlib, sys, tomllib + tag = os.environ["TAG"] + version = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"] + if tag != f"v{version}": + print(f"::error::Tag {tag} does not match pyproject.toml version {version} (expected v{version}).") + sys.exit(1) - name: Give the build a .dev suffix if: github.event_name == 'workflow_dispatch' env: RUN_NUMBER: ${{ github.run_number }} + shell: python run: | - sed -i -E "0,/^version = \"([^\"]+)\"/s//version = \"\\1.dev${RUN_NUMBER}\"/" pyproject.toml - version="$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - case "$version" in - *.dev"$RUN_NUMBER") echo "Building $version" ;; - *) echo "::error::Failed to add a .dev suffix; version is $version." ; exit 1 ;; - esac + import os, pathlib, re, sys, tomllib + run_number = os.environ["RUN_NUMBER"] + path = pathlib.Path("pyproject.toml") + # Write dev version. + path.write_text(re.sub( + r'^version = "([^"]+)"', rf'version = "\1.dev{run_number}"', path.read_text(), + count=1, flags=re.MULTILINE, + )) + # Ensure we wrote the dev version correctly. + version = tomllib.loads(path.read_text())["project"]["version"] + if not version.endswith(f".dev{run_number}"): + print(f"::error::Failed to add a .dev suffix; version is {version}.") + sys.exit(1) + print(f"Building {version}") - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: @@ -119,7 +133,6 @@ jobs: # Only the SBOM is uploaded: the sdist and wheel go to PyPI moments later # and stay there, whereas this is the only convenient copy of the SBOM # (the other being inside the attestation predicate). - - name: Upload SBOM artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: