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: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
- name: Pre-commit (ruff format/check, mypy)
run: uv run pre-commit run --all-files

# Catches invalid workflow files (e.g. a non-existent permission scope),
# which GitHub rejects wholesale — the run then never starts and the only
# trace is a failed "workflow file issue" run.
- name: Lint workflows (actionlint)
run: uvx --from actionlint-py==1.7.12.25 actionlint

- name: Run tests
run: uv run pytest -q

16 changes: 14 additions & 2 deletions .github/workflows/dotbot-act.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
workflows: write
pull-requests: write
issues: write
# NOTE: there is no `workflows:` permission scope — declaring one makes
# GitHub reject the entire workflow file ("Invalid workflow file …"), so
# no run ever starts. Every act run here failed that way between
# 2026-09-04 and the fix, which is why /dotbot never did anything.
# Pushing commits that touch `.github/workflows/*` needs a *token* with
# the `workflow` scope (a PAT — see REPO_ACCESS_TOKEN below), not a
# workflow-level permission. `uvx --from actionlint-py actionlint` and
# tests/test_module_coverage.py now guard against a non-existent scope
# creeping back in.
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -41,7 +49,11 @@ jobs:
# attacker-controlled composite steps with a write token in
# .git/config. Pin the action to a trusted ref below instead.
ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', github.event.issue.number) }}
token: ${{ secrets.REPO_ACCESS_TOKEN }}
# A PAT is preferred: pushes made with the workflow token do not
# trigger further workflow runs, and it cannot modify
# `.github/workflows/*` without the PAT `workflow` scope. Falls back to
# the workflow token so Act still works where no PAT is set.
token: ${{ secrets.REPO_ACCESS_TOKEN || github.token }}
# DOTBOT_ACT_MODEL (org or repo VARIABLE), when set, OVERRIDES the act
# model that would otherwise come from `act.model` in the in-repo
# .openrouter-review.yml. Like DOTBOT_REVIEW_MODELS on the review side,
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ on:
pull_request_review_comment: { types: [created] }
permissions:
contents: write
workflows: write # needed if /dotbot may edit .github/workflows/*
pull-requests: write
issues: write
concurrency:
Expand Down Expand Up @@ -299,12 +298,14 @@ action-authored review threads** as context.
on branches in the main repo, or use a PAT with fork access.
- Grant only what's needed: `contents: write` (push), `pull-requests: write`
(reviews), `issues: write` (summary comments and Act replies).
- **`workflows: write` is required for Act mode** when the `/dotbot` fix touches
`.github/workflows/*`. The default `GITHUB_TOKEN` refuses to push edits to
workflow files without it — you'll see `refusing to allow a GitHub App to
create or update workflow ... without workflows permission`. Add
`workflows: write` to the Act job's `permissions:` block if you want `/dotbot`
to be able to modify workflows.
- **Editing `.github/workflows/*` needs a PAT, not a permission.** There is no
`workflows:` scope for `GITHUB_TOKEN` — declaring one makes GitHub reject the
entire workflow file, so no run ever starts. The `GITHUB_TOKEN` used to push
cannot create or update workflow files either; to let `/dotbot` edit them, set
`secrets.REPO_ACCESS_TOKEN` to a PAT carrying the `workflow` scope (the
workflow already prefers it over `github.token`). Otherwise you'll see
`refusing to allow a GitHub App to create or update workflow … without
workflows permission`.

## Troubleshooting

Expand Down
52 changes: 52 additions & 0 deletions tests/test_module_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,58 @@ def test_action_input_descriptions_contain_no_expressions() -> None:
assert offenders == {}, f"expressions in input descriptions: {sorted(offenders)}"


def test_workflow_permissions_use_known_scopes() -> None:
Comment thread
wezell marked this conversation as resolved.
"""An unknown permission scope makes GitHub reject the whole workflow file.

`workflows: write` in dotbot-act.yml did exactly that — every run was a
"workflow file issue" (28 of them, silent to everyone) and /dotbot never
executed. The scope lists are GitHub's; keep the guard in sync if GitHub
adds one (actionlint in CI reports the same thing).
"""

known_scopes = {
"actions",
"artifact-metadata",
"attestations",
"checks",
"contents",
"deployments",
"discussions",
"id-token",
"issues",
"models",
"packages",
"pages",
"pull-requests",
"repository-projects",
"security-events",
"statuses",
}
shorthands = {"read-all", "write-all"}

offenders: list[str] = []

def _check(label: str, permissions: object) -> None:
"""Record unknown scopes in one `permissions:` block (top-level or job)."""
if isinstance(permissions, str):
if permissions not in shorthands:
offenders.append(f"{label}:{permissions}")
return
if not isinstance(permissions, dict):
return
offenders.extend(f"{label}:{scope}" for scope in permissions if scope not in known_scopes)

for path in sorted(Path(".github/workflows").glob("*.yml")):
Comment thread
wezell marked this conversation as resolved.
workflow = yaml.safe_load(path.read_text(encoding="utf-8"))
# Both levels can brick the workflow: a bad scope at the top of the file
# is as fatal as one inside a job.
_check(f"{path.name}:top-level", workflow.get("permissions"))
for job_name, job in (workflow.get("jobs") or {}).items():
_check(f"{path.name}:{job_name}", job.get("permissions"))

assert offenders == [], f"unknown permission scopes: {sorted(offenders)}"


def test_edit_workflow_helpers_cover_reply_formatting_and_context_normalization() -> None:
truncated = _format_edit_reply("x" * 3605, pushed=False, dry_run=False, changed=True)
assert "not pushed" in truncated
Expand Down
Loading