Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/api-latency-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,14 @@ on:

# Least privilege: this workflow only builds a wheel and probes a local
# dashboard. It never writes to the repository or any GitHub API surface.
# Any job needing more must elevate with its own job-level `permissions:`.
permissions:
contents: read

concurrency:
group: api-latency-smoke-${{ github.ref }}
cancel-in-progress: true

# Least privilege: these jobs only read the repo. Any job needing more
# must elevate with its own job-level `permissions:` block.
permissions:
contents: read

jobs:
smoke:
name: ${{ matrix.source == 'pypi' && 'PyPI nightly' || 'PR build' }}
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/auto-deploy-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ on:
types: [completed]
workflow_dispatch:

# Every cross-repo action below (checkout of clawmetry-cloud, branch push, PR
# create/close/merge) authenticates as `secrets.CLOUD_REPO_PAT`, which this
# block does not govern — a PAT carries its own scopes. The only thing that
# rides on GITHUB_TOKEN is the checkout of THIS repo, which needs `contents:
# read` and nothing more. Deliberately kept read-only so a future step cannot
# quietly acquire write on clawmetry via the ambient token.
permissions:
contents: read

jobs:
pin-version:
name: Rebuild cloud with latest OSS
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ on:
branches: [main, master]
pull_request:

# Every job here only reads the repository: checkout, install, run tests,
# upload an artifact. Nothing calls the GitHub API with GITHUB_TOKEN, pushes
# a commit, tags, comments on a PR or touches a Release, so the whole
# workflow runs on the read-only default and no job needs an elevated block.
permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/desktop-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ on:
- 'v*.*.*'
workflow_dispatch: {}

# Least-privilege floor for the three BUILD jobs (macos / linux inherit this;
# windows re-declares it alongside id-token). The `release` job, and only that
# job, elevates to `contents: write` so softprops/action-gh-release can attach
# installers to the tag's Release and publish it. Keep the elevation on that
# job — a workflow-wide `contents: write` would hand every build job, and each
# third-party action it runs, the ability to write to the repository.
permissions:
contents: read

jobs:
macos:
name: macOS .app + .dmg (signed + notarized when secrets are set)
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/release-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ on:
types: [closed]
branches: [main]

# Read-only floor. This workflow's single job re-declares its own scopes
# below, and a job-level block REPLACES the top level rather than merging
# with it, so this value never actually applies to `release` — it is here so
# the default for any job added later is read-only, and so the workflow
# stops reading as "inherits whatever the repository default happens to be".
# The publish/tag/PR scopes stay on the job that needs them, not up here.
permissions:
contents: read

jobs:
release:
name: Bump version & publish to PyPI
Expand Down
52 changes: 52 additions & 0 deletions tests/test_workflow_yaml_valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,58 @@ def test_workflow_parses_as_yaml(path: str) -> None:
)


class _DuplicateKeyLoader(yaml.SafeLoader):
"""A SafeLoader that refuses a mapping with a repeated key.

PyYAML's own resolution is last-one-wins, silently: ``yaml.safe_load`` on a
file with two top-level ``permissions:`` blocks returns a perfectly good
dict and raises nothing. GitHub Actions does the opposite -- it rejects the
file outright and fails the run at startup -- so a duplicate key is exactly
the class of break ``test_workflow_parses_as_yaml`` cannot see.
"""


def _no_duplicate_keys(loader, node, deep=False):
seen = set()
for key_node, _ in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in seen:
raise yaml.YAMLError(
f"duplicate key {key!r} at line {key_node.start_mark.line + 1}"
)
seen.add(key)
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)


_DuplicateKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys
)


@pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename)
def test_workflow_has_no_duplicate_keys(path: str) -> None:
"""No mapping may repeat a key. GitHub rejects the file if one does.

Burned by the token-permissions hardening pass: two separate PRs each
added a top-level ``permissions: contents: read`` block to
api-latency-smoke.yml. Both were individually correct, neither conflicted
in git, and the pre-merge check was ``yaml.safe_load`` -- which happily
kept the second and returned a valid dict. The workflow was dead on main
from the moment the second one merged, its runs listed under the file path
with zero jobs, and the API-latency safety net was off.
"""
with open(path, encoding="utf-8") as fh:
source = fh.read()
try:
yaml.load(source, _DuplicateKeyLoader)
except yaml.YAMLError as exc: # pragma: no cover - message is the point
pytest.fail(
f"{os.path.basename(path)} repeats a mapping key ({exc}). PyYAML "
"keeps the last one without complaining, but GitHub rejects the "
"workflow and fails every run at startup before any step executes."
)


@pytest.mark.parametrize("path", _workflow_files(), ids=os.path.basename)
def test_workflow_has_required_top_level_keys(path: str) -> None:
"""A parseable file can still be a non-workflow. Check the shape."""
Expand Down
Loading