From 3967778ea9836f29387d2ab7b23f726c8f933b03 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:03:50 -0500 Subject: [PATCH 01/10] Add built-in GitHub workflow step Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec94c538-a8b2-4c39-81fc-3ba5f0a59ce6 --- docs/reference/workflows.md | 55 +++ src/specify_cli/workflows/__init__.py | 2 + .../workflows/step/github/__init__.py | 353 ++++++++++++++++++ tests/test_workflows.py | 4 +- tests/workflows/test_github_step.py | 294 +++++++++++++++ workflows/ARCHITECTURE.md | 3 +- 6 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 src/specify_cli/workflows/step/github/__init__.py create mode 100644 tests/workflows/test_github_step.py diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index b99fbea42c..0dadd45a0e 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -570,6 +570,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | | `prompt` | Send an arbitrary prompt to the AI coding agent | | `shell` | Execute a shell command and capture output | +| `github` | Post/retrieve GitHub comments or check out a PR | | `init` | Bootstrap a project (like `specify init`) | | `slot` | Named workflow slot; skipped when unfilled | | `gate` | Pause for human approval before continuing | @@ -582,6 +583,60 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta > **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands. +### GitHub step + +`type: github` uses the installed `gh` CLI and its active authentication for a +repository whose `origin` points to `github.com`. The token needs read access +for PRs/issues, and issue-comment write access only for `comment`. The step +does not request or elevate permissions. It does not interpret issue/comment +contents as workflow instructions or apply labels. GitHub operations require +network access; tests should mock the API rather than post live comments. + +```yaml +- id: publish-plan + type: github + operation: comment + target: issue + number: "{{ inputs.issue_number }}" + body_file: docs/plan.md + artifact: reviewed-plan + maintainer_action: + summary: "Review the plan before scheduling implementation." + possible_labels: [ready-for-review] + +- id: retrieve-plan + type: github + operation: fetch-artifact + target: issue + number: "{{ inputs.issue_number }}" + artifact: reviewed-plan + write_to: docs/retrieved-plan.md + +- id: checkout + type: github + operation: checkout-pr + number: "{{ inputs.pr_number }}" +``` + +For `comment`, supply exactly one of `body` (text), `body_file` (UTF-8 file), +or `body_files` (non-empty list of UTF-8 files joined with two newlines). +`target` is `issue` or `pull_request`; `number` must resolve to a positive +integer. `artifact` is an optional stable alphanumeric identifier (hyphens +and underscores allowed), required by `fetch-artifact`. `maintainer_action` +adds a **proposal only** section with `summary` and `possible_labels` to the +comment; it never changes GitHub labels. The posted comment includes a +digest-bearing marker tying it to the workflow run and step. Re-running the +same step in the same run reuses an identical comment rather than posting a +duplicate; changed content or a changed author fails instead. `fetch-artifact` +requires exactly one intact, previously marked comment authored by the active +GitHub account. It writes only to a *new* relative file under the project +root, with an existing directory; symlinks and overwrites are refused. +`checkout-pr` fetches GitHub's `pull//head` ref, verifies its SHA +matches the API's PR head, and checks out that commit detached. Local changes +that prevent checkout cause the step to fail. +GitHub steps cannot be nested in `fan-out`: an item has no distinct run/step +identity for safe comment retries. + ### Custom step packages Custom step types are installed with `specify workflow step`. A step is a diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py index baa3b8fa09..18e40a098f 100644 --- a/src/specify_cli/workflows/__init__.py +++ b/src/specify_cli/workflows/__init__.py @@ -49,6 +49,7 @@ def _register_builtin_steps() -> None: from .step.fan_in import FanInStep from .step.fan_out import FanOutStep from .step.gate import GateStep + from .step.github import GitHubStep from .step.if_then import IfThenStep from .step.init import InitStep from .step.prompt import PromptStep @@ -62,6 +63,7 @@ def _register_builtin_steps() -> None: _register_step(FanInStep()) _register_step(FanOutStep()) _register_step(GateStep()) + _register_step(GitHubStep()) _register_step(IfThenStep()) _register_step(InitStep()) _register_step(PromptStep()) diff --git a/src/specify_cli/workflows/step/github/__init__.py b/src/specify_cli/workflows/step/github/__init__.py new file mode 100644 index 0000000000..b02015c700 --- /dev/null +++ b/src/specify_cli/workflows/step/github/__init__.py @@ -0,0 +1,353 @@ +"""GitHub workflow step: post comments, retrieve posted artifacts, or check out a PR.""" + +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,99}\Z") +_NUMBER = re.compile(r"[1-9][0-9]*\Z") +_ORIGIN = re.compile( + r"(?:https://github\.com/|git@github\.com:)([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?\Z" +) +_MARKER = re.compile( + r"\Z" +) +_MARKER_PREFIX = "") + viewer = self._viewer(root) + existing = [] + for comment in _comments(root, issue_url): + marked = _marked(comment) + if marked and marked[2:4] == (run_id, step_id): + existing.append((comment, marked)) + if len(existing) > 1: + raise ValueError("Multiple comments exist for this run and step") + if existing: + comment, marked = existing[0] + if marked[0] != body or marked[1] != artifact or _author(comment) != viewer: + raise ValueError( + "Existing run/step comment differs or is not owned by the authenticated user" + ) + comment_id = comment.get("id") + else: + posted = _api(root, f"{issue_url}/comments", method="POST", data={"body": marked_body}) + if not isinstance(posted, dict): + raise ValueError("GitHub returned an invalid posted comment") + comment_id = posted.get("id") + if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: + raise ValueError("GitHub comment has no valid ID") + return StepResult(output={"repository": repo, "number": number, "comment_id": comment_id, + "artifact": artifact if artifact != "-" else None}) + + def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, + issue_url: str) -> StepResult: + name = _string(_resolved(config["write_to"], context), "write_to") + destination = _project_path(root, name, writing=True) + viewer = self._viewer(root) + matches = [] + for comment in _comments(root, issue_url): + marked = _marked(comment) + if marked and marked[1] == config["artifact"]: + matches.append((comment, marked[0])) + if len(matches) != 1: + raise ValueError( + f"Expected exactly one trusted artifact {config['artifact']!r}; " + f"found {len(matches)}" + ) + comment, body = matches[0] + if _author(comment) != viewer: + raise ValueError("GitHub artifact was not posted by the authenticated user") + comment_id = comment.get("id") + if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: + raise ValueError("GitHub artifact has no valid comment ID") + try: + with destination.open("x", encoding="utf-8") as stream: + stream.write(body) + except FileExistsError as exc: + raise ValueError(f"Destination already exists: {destination}") from exc + except OSError as exc: + raise ValueError(f"Cannot write artifact: {exc}") from exc + return StepResult(output={"path": str(destination), "comment_id": comment_id}) + + @staticmethod + def _checkout(root: Path, url: str, number: int) -> StepResult: + pr = _api(root, f"{url}/pulls/{number}") + head = pr.get("head") if isinstance(pr, dict) else None + sha = head.get("sha") if isinstance(head, dict) else None + if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{40}", sha): + raise ValueError("GitHub PR has no valid head SHA") + _run(["git", "fetch", "origin", f"pull/{number}/head"], root) + fetched = _run(["git", "rev-parse", "FETCH_HEAD"], root) + if fetched != sha: + raise ValueError( + "Fetched PR head differs from the GitHub PR head; retry after the PR settles" + ) + _run(["git", "-c", "advice.detachedHead=false", "checkout", "--detach", sha], root) + if _run(["git", "rev-parse", "HEAD"], root) != sha: + raise ValueError("Checked-out HEAD differs from the GitHub PR head") + return StepResult(output={"head_sha": sha, "number": number}) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 9716c6578a..f830fa9867 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4,7 +4,7 @@ - Step registry & auto-discovery - Base classes (StepBase, StepContext, StepResult) - Expression engine -- All 12 built-in step types +- All 13 built-in step types - Workflow definition loading & validation - Workflow engine execution & state persistence - Workflow catalog & registry @@ -106,7 +106,7 @@ def test_all_step_types_registered(self): expected = { "command", "shell", "prompt", "gate", "if", "switch", - "while", "do-while", "fan-out", "fan-in", "init", "slot", + "while", "do-while", "fan-out", "fan-in", "init", "slot", "github", } assert expected.issubset(set(STEP_REGISTRY.keys())) diff --git a/tests/workflows/test_github_step.py b/tests/workflows/test_github_step.py new file mode 100644 index 0000000000..e6d4b918aa --- /dev/null +++ b/tests/workflows/test_github_step.py @@ -0,0 +1,294 @@ +"""Deterministic tests for the built-in GitHub workflow step.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli.workflows import BUILTIN_STEP_TYPES, get_step_type +from specify_cli.workflows.base import RunStatus, StepContext, StepStatus +from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine +from specify_cli.workflows.step import github + + +@pytest.fixture +def context(tmp_path: Path) -> StepContext: + return StepContext( + inputs={"issue": "12", "pr": 7}, + project_root=str(tmp_path), + run_id="run-1", + ) + + +@pytest.fixture +def api(monkeypatch): + comments: list[dict] = [] + calls: list[tuple[str, str]] = [] + + def fake_api(root, endpoint, *, method="GET", data=None, paginated=False): + calls.append((method, endpoint)) + if endpoint.endswith("/user"): + return {"login": "maintainer"} + if endpoint.endswith("/pulls/7"): + return {"number": 7, "head": {"sha": "a" * 40}} + if endpoint.endswith("/issues/12"): + return {"number": 12} + if endpoint.endswith("/comments?per_page=100"): + assert paginated + return [comments[:]] + if endpoint.endswith("/comments") and method == "POST": + comment = {"id": len(comments) + 1, "body": data["body"], + "user": {"login": "maintainer"}} + comments.append(comment) + return comment + raise AssertionError(f"Unexpected API call: {method} {endpoint}") + + monkeypatch.setattr(github, "_api", fake_api) + monkeypatch.setattr(github, "_identity", lambda root: ( + "owner/repo", "https://api.github.com/repos/owner/repo", + )) + return comments, calls + + +def config(**kwargs): + definition = {"id": "post-plan", "type": "github", "operation": "comment", + "target": "issue", "number": "{{ inputs.issue }}"} + definition.update(kwargs) + return definition + + +def test_registered_builtin_and_validation(): + step = get_step_type("github") + assert step is not None + assert "github" in BUILTIN_STEP_TYPES + assert step.validate(config(body="text")) == [] + assert step.validate(config(body_file="plan.md")) == [] + assert step.validate(config(body_files=["plan.md"])) == [] + assert step.validate(config(body="text", body_file="plan.md")) + assert step.validate(config(body_files=[])) + assert step.validate(config(body="text", number=True)) + assert step.validate(config(body="text", target="repository")) + assert step.validate(config(body="text", unexpected="field")) + assert step.validate(config(body="text", maintainer_action={"summary": "x"})) + assert step.validate({"id": "fetch", "type": "github", "operation": "fetch-artifact", + "target": "issue", "number": 12, "write_to": "result.md"}) + assert step.validate({"id": "checkout", "type": "github", "operation": "checkout-pr", + "number": 0}) + + +def test_comment_retry_is_idempotent_and_changed_content_fails(context, api): + comments, calls = api + step = get_step_type("github") + definition = config(body="Plan for {{ inputs.issue }}", artifact="plan", + maintainer_action={"summary": "Review only", + "possible_labels": ["approved"]}) + first = step.execute(definition, context) + again = step.execute(definition, context) + assert first.status == again.status == StepStatus.COMPLETED + assert first.output["comment_id"] == again.output["comment_id"] == 1 + assert len(comments) == 1 + assert "Plan for 12" in comments[0]["body"] + assert "Possible labels: approved" in comments[0]["body"] + assert not any("/labels" in endpoint for _, endpoint in calls) + assert len([method for method, _ in calls if method == "POST"]) == 1 + changed = step.execute(config(body="Different", artifact="plan"), context) + assert changed.status == StepStatus.FAILED + assert "differs" in changed.error + assert len(comments) == 1 + + +def test_comment_files_and_fetch_artifact(context, api): + comments, _ = api + root = Path(context.project_root) + (root / "one.md").write_text("First") + (root / "two.md").write_text("Second") + step = get_step_type("github") + posted = step.execute(config(body_files=["one.md", "two.md"], artifact="report"), context) + assert posted.status == StepStatus.COMPLETED + result = step.execute({"id": "retrieve", "type": "github", "operation": "fetch-artifact", + "target": "issue", "number": 12, "artifact": "report", + "write_to": "result.md"}, context) + assert result.status == StepStatus.COMPLETED, result.error + assert (root / "result.md").read_text() == "First\n\nSecond" + assert result.output["comment_id"] == comments[0]["id"] + again = step.execute({"id": "retrieve", "operation": "fetch-artifact", + "target": "issue", "number": 12, "artifact": "report", + "write_to": "result.md"}, context) + assert again.status == StepStatus.FAILED + assert (root / "result.md").read_text() == "First\n\nSecond" + + +def test_engine_executes_github_yaml_without_shell(context, api): + comments, _ = api + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: github-pipeline + name: GitHub pipeline + version: "1.0.0" +inputs: + issue: + type: number + required: true +steps: + - id: publish + type: github + operation: comment + target: issue + number: "{{ inputs.issue }}" + body: "Plan for {{ inputs.issue }}" + artifact: plan +""") + engine = WorkflowEngine(Path(context.project_root)) + assert engine.validate(definition) == [] + state = engine.execute(definition, {"issue": "12"}, run_id="pipeline1") + assert state.status == RunStatus.COMPLETED + assert state.step_results["publish"]["output"]["comment_id"] == 1 + assert len(comments) == 1 + + +def test_pull_request_comment_uses_issue_comment_endpoint(context, api): + comments, calls = api + definition = config(body="PR feedback", target="pull_request", number=7) + result = get_step_type("github").execute(definition, context) + assert result.status == StepStatus.COMPLETED, result.error + assert get_step_type("github").execute(definition, context).status == StepStatus.COMPLETED + assert len(comments) == 1 + assert any(url.endswith("/pulls/7") for _, url in calls) + assert any(method == "POST" and url.endswith("/issues/7/comments") + for method, url in calls) + + +@pytest.mark.parametrize("change,expected", [ + (lambda comments: comments[0].update(body=comments[0]["body"].replace("First", "Altered")), "digest"), + (lambda comments: comments[0].update(user={"login": "someone-else"}), "not posted"), + (lambda comments: comments.append(dict(comments[0], id=2)), "found 2"), +]) +def test_fetch_rejects_untrusted_or_ambiguous(context, api, change, expected): + comments, _ = api + step = get_step_type("github") + assert step.execute(config(body="First", artifact="report"), context).status == StepStatus.COMPLETED + change(comments) + result = step.execute({"id": "retrieve", "operation": "fetch-artifact", + "target": "issue", "number": 12, "artifact": "report", + "write_to": "result.md"}, context) + assert result.status == StepStatus.FAILED + assert expected in result.error + assert not (Path(context.project_root) / "result.md").exists() + + +@pytest.mark.parametrize("name", ["../elsewhere", "/tmp/elsewhere", "absent/result.md", "link/result.md"]) +def test_fetch_rejects_unsafe_destinations(context, api, name, tmp_path): + root = Path(context.project_root) + (root / "link").symlink_to(tmp_path.parent, target_is_directory=True) + step = get_step_type("github") + assert step.execute(config(body="text", artifact="report"), context).status == StepStatus.COMPLETED + result = step.execute({"id": "fetch", "operation": "fetch-artifact", "target": "issue", + "number": 12, "artifact": "report", "write_to": name}, context) + assert result.status == StepStatus.FAILED + + +def test_bad_identifiers_and_missing_artifact_fail_without_post(context, api): + comments, calls = api + step = get_step_type("github") + for number in (0, "-1", True, "1; echo bad", None): + result = step.execute(config(body="hello", number=number), context) + assert result.status == StepStatus.FAILED + assert "number" in result.error + result = step.execute({"id": "fetch", "operation": "fetch-artifact", "target": "issue", + "number": 12, "artifact": "missing", "write_to": "result.md"}, context) + assert result.status == StepStatus.FAILED + assert "found 0" in result.error + assert not comments + assert not any(method == "POST" for method, _ in calls) + + +def test_comment_rejects_symlinked_source_and_fan_out(context, api, tmp_path): + root = Path(context.project_root) + (root / "link.md").symlink_to(tmp_path.parent / "outside.md") + step = get_step_type("github") + result = step.execute(config(body_file="link.md"), context) + assert result.status == StepStatus.FAILED + assert "Symlinked" in result.error + context.inside_fan_out = True + result = step.execute(config(body="hi"), context) + assert result.status == StepStatus.FAILED + assert "fan-out" in result.error + + +def test_api_error_fails_without_success_shaped_output(context, api, monkeypatch): + monkeypatch.setattr(github, "_api", lambda *args, **kwargs: (_ for _ in ()).throw( + ValueError("GitHub request failed (exit code 403)"))) + result = get_step_type("github").execute(config(body="hi"), context) + assert result.status == StepStatus.FAILED + assert "403" in result.error + assert result.output == {} + + +def test_api_posts_json_on_stdin_and_slurps_comment_pages(context, monkeypatch): + observed = [] + + def fake_run(args, root, *, input_text=None): + observed.append((args, input_text)) + return "[[]]" if "--paginate" in args else '{"id": 1}' + + monkeypatch.setattr(github, "_run", fake_run) + root = Path(context.project_root) + assert github._api(root, "https://api.github.com/repos/o/r/issues/1/comments", + method="POST", data={"body": "private body"}) == {"id": 1} + assert observed[0][1] == '{"body": "private body"}' + assert "private body" not in " ".join(observed[0][0]) + assert github._api(root, "https://api.github.com/repos/o/r/issues/1/comments", + paginated=True) == [[]] + assert observed[1][0][-2:] == ["--paginate", "--slurp"] + + +def test_identity_rejects_non_github_origin(context, monkeypatch): + assert github._ORIGIN.fullmatch("https://evil.example/owner/repo.git") is None + assert github._ORIGIN.fullmatch("https://github.com/owner/repo.git") + monkeypatch.setattr(github, "_run", lambda args, root, **kwargs: "https://evil.example/owner/repo.git") + with pytest.raises(ValueError, match="github.com origin"): + github._identity(Path(context.project_root)) + + +def test_comment_rejects_wrong_target(context, api, monkeypatch): + step = get_step_type("github") + original_api = github._api + + def wrong_target(root, endpoint, **kwargs): + if endpoint.endswith("/issues/12"): + return {"number": 12, "pull_request": {}} + return original_api(root, endpoint, **kwargs) + + monkeypatch.setattr(github, "_api", wrong_target) + bad = step.execute(config(body="hi"), context) + assert bad.status == StepStatus.FAILED + assert "does not match" in bad.error + + +def test_checkout_verifies_head_before_checkout(context, api, monkeypatch): + commands = [] + sha = "a" * 40 + + def fake_run(args, root, *, input_text=None): + commands.append(args) + if args[:2] == ["git", "rev-parse"]: + return sha + return "" + + monkeypatch.setattr(github, "_run", fake_run) + step = get_step_type("github") + definition = {"id": "checkout", "type": "github", "operation": "checkout-pr", + "number": "{{ inputs.pr }}"} + result = step.execute(definition, context) + assert result.status == StepStatus.COMPLETED + assert result.output["head_sha"] == sha + assert ["git", "fetch", "origin", "pull/7/head"] in commands + assert ["git", "-c", "advice.detachedHead=false", "checkout", "--detach", sha] in commands + assert commands[-1] == ["git", "rev-parse", "HEAD"] + commands.clear() + monkeypatch.setattr(github, "_run", lambda args, root, **kwargs: "b" * 40) + failed = step.execute(definition, context) + assert failed.status == StepStatus.FAILED + assert "differs" in failed.error diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md index 6070823a2a..8fe70a8709 100644 --- a/workflows/ARCHITECTURE.md +++ b/workflows/ARCHITECTURE.md @@ -79,13 +79,14 @@ When a `gate` step pauses execution, the engine persists `current_step_index` an ## Step Types -The engine ships with 12 built-in step types, each in its own subpackage under `src/specify_cli/workflows/step/`: +The engine ships with 13 built-in step types, each in its own subpackage under `src/specify_cli/workflows/step/`: | Type Key | Class | Purpose | Returns `next_steps`? | |----------|-------|---------|-----------------------| | `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No | | `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No | | `shell` | `ShellStep` | Run a shell command, capture output | No | +| `github` | `GitHubStep` | GitHub comments, artifacts, and PR checkout | No | | `init` | `InitStep` | Bootstrap a project (equivalent to `specify init`) | No | | `slot` | `SlotStep` | Named workflow slot; skipped when unfilled | No | | `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) | From e8f5f35a28db99e3a73f3cbafaab59698182ad87 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:09:48 -0500 Subject: [PATCH 02/10] Preserve GitHub artifact bytes across fresh workflow runs Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec94c538-a8b2-4c39-81fc-3ba5f0a59ce6 --- docs/reference/workflows.md | 15 +- .../workflows/step/github/__init__.py | 104 +++++++++++--- tests/workflows/test_github_step.py | 131 +++++++++++++++++- 3 files changed, 228 insertions(+), 22 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 0dadd45a0e..c04e1cfc30 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -591,6 +591,13 @@ for PRs/issues, and issue-comment write access only for `comment`. The step does not request or elevate permissions. It does not interpret issue/comment contents as workflow instructions or apply labels. GitHub operations require network access; tests should mock the API rather than post live comments. +In GitHub Actions, a repository-scoped `GITHUB_TOKEN` is supported: when +`GITHUB_ACTIONS=true` and `GITHUB_REPOSITORY` matches `origin`, the step +verifies that the active credential is an installation token restricted to +that repository, then checks the author against `github-actions[bot]`. +User tokens (including user tokens used inside Actions) use `GET /user` for +author verification instead. Other app installation tokens are not accepted +as the Actions bot identity. ```yaml - id: publish-plan @@ -628,9 +635,11 @@ comment; it never changes GitHub labels. The posted comment includes a digest-bearing marker tying it to the workflow run and step. Re-running the same step in the same run reuses an identical comment rather than posting a duplicate; changed content or a changed author fails instead. `fetch-artifact` -requires exactly one intact, previously marked comment authored by the active -GitHub account. It writes only to a *new* relative file under the project -root, with an existing directory; symlinks and overwrites are refused. +requires exactly one intact, previously marked comment authored by the +verified account/bot. It writes only the original artifact bytes, excluding +the maintainer-action footer and marker, to a *new* relative file under the +project root. Missing parent directories are created inside the project +after the comment is verified; symlinks and overwrites are refused. `checkout-pr` fetches GitHub's `pull//head` ref, verifies its SHA matches the API's PR head, and checks out that commit detached. Local changes that prevent checkout cause the step to fail. diff --git a/src/specify_cli/workflows/step/github/__init__.py b/src/specify_cli/workflows/step/github/__init__.py index b02015c700..2d7760bc6d 100644 --- a/src/specify_cli/workflows/step/github/__init__.py +++ b/src/specify_cli/workflows/step/github/__init__.py @@ -4,6 +4,7 @@ import hashlib import json +import os import re import subprocess from pathlib import Path @@ -18,9 +19,9 @@ r"(?:https://github\.com/|git@github\.com:)([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?\Z" ) _MARKER = re.compile( - r"\Z" + r"bytes=([0-9]+) sha256=([a-f0-9]{64}) -->\Z" ) _MARKER_PREFIX = "") - viewer = self._viewer(root) + marked_body = (f"{body}\n\n") + viewer = self._viewer(root, repo) existing = [] for comment in _comments(root, issue_url): marked = _marked(comment) @@ -298,6 +365,8 @@ def _comment(self, config: dict[str, Any], context: StepContext, root: Path, posted = _api(root, f"{issue_url}/comments", method="POST", data={"body": marked_body}) if not isinstance(posted, dict): raise ValueError("GitHub returned an invalid posted comment") + if _author(posted) != viewer: + raise ValueError("Posted comment author does not match the authenticated identity") comment_id = posted.get("id") if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: raise ValueError("GitHub comment has no valid ID") @@ -305,15 +374,15 @@ def _comment(self, config: dict[str, Any], context: StepContext, root: Path, "artifact": artifact if artifact != "-" else None}) def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, - issue_url: str) -> StepResult: + repo: str, issue_url: str) -> StepResult: name = _string(_resolved(config["write_to"], context), "write_to") destination = _project_path(root, name, writing=True) - viewer = self._viewer(root) + viewer = self._viewer(root, repo) matches = [] for comment in _comments(root, issue_url): marked = _marked(comment) if marked and marked[1] == config["artifact"]: - matches.append((comment, marked[0])) + matches.append((comment, marked[4])) if len(matches) != 1: raise ValueError( f"Expected exactly one trusted artifact {config['artifact']!r}; " @@ -326,8 +395,7 @@ def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: raise ValueError("GitHub artifact has no valid comment ID") try: - with destination.open("x", encoding="utf-8") as stream: - stream.write(body) + _write_artifact(root, destination, body) except FileExistsError as exc: raise ValueError(f"Destination already exists: {destination}") from exc except OSError as exc: diff --git a/tests/workflows/test_github_step.py b/tests/workflows/test_github_step.py index e6d4b918aa..fa25019cf7 100644 --- a/tests/workflows/test_github_step.py +++ b/tests/workflows/test_github_step.py @@ -119,6 +119,120 @@ def test_comment_files_and_fetch_artifact(context, api): assert (root / "result.md").read_text() == "First\n\nSecond" +def test_fetch_creates_fresh_nested_parent_and_restores_only_artifact(context, api): + root = Path(context.project_root) + artifact = "# Assessment\r\n\r\nCafé\r\n\r\n### Maintainer action (proposal only)\r\nLiteral artifact text.\r\n" + (root / "assessment.md").write_bytes(artifact.encode("utf-8")) + step = get_step_type("github") + definition = config(body_file="assessment.md", artifact="assessment", + maintainer_action={"summary": "Please review before proceeding.", + "possible_labels": ["ready"]}) + assert step.execute(definition, context).status == StepStatus.COMPLETED + destination = root / ".specify" / "bugs" / "fresh-label" / "assessment.md" + assert not destination.parent.exists() + result = step.execute({"id": "rehydrate", "type": "github", "operation": "fetch-artifact", + "target": "issue", "number": 12, "artifact": "assessment", + "write_to": ".specify/bugs/fresh-label/assessment.md"}, context) + assert result.status == StepStatus.COMPLETED, result.error + assert destination.read_bytes() == artifact.encode("utf-8") + assert "Please review before proceeding" not in destination.read_text(encoding="utf-8") + assert step.execute(definition, context).status == StepStatus.COMPLETED + + +def test_actions_installation_token_verifies_bot_author(context, api, monkeypatch): + comments, _ = api + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + original_api = github._api + + def actions_api(root, endpoint, **kwargs): + if endpoint.endswith("/installation/repositories"): + return {"total_count": 1, "repositories": [{"full_name": "owner/repo"}]} + if endpoint.endswith("/user"): + raise AssertionError("Actions installation tokens do not need /user") + result = original_api(root, endpoint, **kwargs) + if kwargs.get("method") == "POST": + result["user"] = {"login": "github-actions[bot]"} + return result + + monkeypatch.setattr(github, "_api", actions_api) + step = get_step_type("github") + definition = config(body="Artifact", artifact="report") + assert step.execute(definition, context).status == StepStatus.COMPLETED + assert step.execute(definition, context).status == StepStatus.COMPLETED + result = step.execute({"id": "fetch", "operation": "fetch-artifact", "target": "issue", + "number": 12, "artifact": "report", "write_to": "new/fix.md"}, context) + assert result.status == StepStatus.COMPLETED, result.error + assert (Path(context.project_root) / "new" / "fix.md").read_bytes() == b"Artifact" + assert len(comments) == 1 + + +@pytest.mark.parametrize("installation", [ + {"total_count": 1, "repositories": [{"full_name": "elsewhere/repo"}]}, + {"total_count": 2, "repositories": [{"full_name": "owner/repo"}]}, + {"total_count": 1, "repositories": [{"full_name": 42}]}, +]) +def test_actions_rejects_untrusted_installation(context, api, monkeypatch, installation): + comments, _ = api + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + original_api = github._api + monkeypatch.setattr( + github, "_api", + lambda root, endpoint, **kwargs: installation + if endpoint.endswith("/installation/repositories") + else original_api(root, endpoint, **kwargs), + ) + result = get_step_type("github").execute(config(body="text"), context) + assert result.status == StepStatus.FAILED + assert "scoped to this repository" in result.error + assert not comments + + +def test_actions_user_token_uses_user_identity(context, api, monkeypatch): + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + original_api = github._api + + def user_token(root, endpoint, **kwargs): + if endpoint.endswith("/installation/repositories"): + raise ValueError("Installation endpoint unavailable to user token") + return original_api(root, endpoint, **kwargs) + + monkeypatch.setattr(github, "_api", user_token) + assert get_step_type("github").execute(config(body="text"), context).status == StepStatus.COMPLETED + + +def test_actions_rejects_comment_from_wrong_bot(context, api, monkeypatch): + comments, _ = api + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + original_api = github._api + monkeypatch.setattr( + github, "_api", + lambda root, endpoint, **kwargs: ( + {"total_count": 1, "repositories": [{"full_name": "owner/repo"}]} + if endpoint.endswith("/installation/repositories") + else original_api(root, endpoint, **kwargs) + ), + ) + result = get_step_type("github").execute(config(body="text"), context) + assert result.status == StepStatus.FAILED + assert "author does not match" in result.error + assert len(comments) == 1 # The server posted, but success is not reported. + + +def test_fetch_does_not_create_directories_for_untrusted_artifact(context, api): + step = get_step_type("github") + root = Path(context.project_root) + result = step.execute({"id": "rehydrate", "operation": "fetch-artifact", + "target": "issue", "number": 12, "artifact": "missing", + "write_to": ".specify/bugs/fresh-label/fix.md"}, context) + assert result.status == StepStatus.FAILED + assert "found 0" in result.error + assert not (root / ".specify").exists() + + def test_engine_executes_github_yaml_without_shell(context, api): comments, _ = api definition = WorkflowDefinition.from_string(""" @@ -178,7 +292,7 @@ def test_fetch_rejects_untrusted_or_ambiguous(context, api, change, expected): assert not (Path(context.project_root) / "result.md").exists() -@pytest.mark.parametrize("name", ["../elsewhere", "/tmp/elsewhere", "absent/result.md", "link/result.md"]) +@pytest.mark.parametrize("name", ["../elsewhere", "/tmp/elsewhere", "link/result.md"]) def test_fetch_rejects_unsafe_destinations(context, api, name, tmp_path): root = Path(context.project_root) (root / "link").symlink_to(tmp_path.parent, target_is_directory=True) @@ -189,6 +303,21 @@ def test_fetch_rejects_unsafe_destinations(context, api, name, tmp_path): assert result.status == StepStatus.FAILED +def test_fetch_rejects_symlinked_missing_parent_and_existing_destination(context, api, tmp_path): + root = Path(context.project_root) + (root / ".specify").mkdir() + (root / ".specify" / "bugs").symlink_to(tmp_path.parent, target_is_directory=True) + (root / "existing.md").write_text("keep", encoding="utf-8") + step = get_step_type("github") + assert step.execute(config(body="original", artifact="report"), context).status == StepStatus.COMPLETED + for name in (".specify/bugs/new/fix.md", "existing.md"): + result = step.execute({"id": "fetch", "operation": "fetch-artifact", "target": "issue", + "number": 12, "artifact": "report", "write_to": name}, context) + assert result.status == StepStatus.FAILED + assert (root / "existing.md").read_text() == "keep" + assert not (tmp_path.parent / "new").exists() + + def test_bad_identifiers_and_missing_artifact_fail_without_post(context, api): comments, calls = api step = get_step_type("github") From 8f24674b8b5d20d2fa27da775e8ff8bd96b91a36 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:11:17 -0500 Subject: [PATCH 03/10] Clarify Actions bot identity constraint Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec94c538-a8b2-4c39-81fc-3ba5f0a59ce6 --- docs/reference/workflows.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index c04e1cfc30..f1cdafa470 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -596,8 +596,8 @@ In GitHub Actions, a repository-scoped `GITHUB_TOKEN` is supported: when verifies that the active credential is an installation token restricted to that repository, then checks the author against `github-actions[bot]`. User tokens (including user tokens used inside Actions) use `GET /user` for -author verification instead. Other app installation tokens are not accepted -as the Actions bot identity. +author verification instead. A non-Actions app token cannot post as +`github-actions[bot]`; an unexpected post author fails the step. ```yaml - id: publish-plan From 21ab9e5b90c3ba84fd311dec4dc5a5301e4bfe51 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:24:33 -0500 Subject: [PATCH 04/10] Address GitHub workflow step review findings Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec94c538-a8b2-4c39-81fc-3ba5f0a59ce6 --- docs/reference/workflows.md | 8 ++- extensions/github/README.md | 4 +- .../workflows/step/github/__init__.py | 33 ++++----- tests/workflows/test_github_step.py | 69 ++++++++++++++++++- 4 files changed, 92 insertions(+), 22 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index f1cdafa470..820f90f62a 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -639,7 +639,13 @@ requires exactly one intact, previously marked comment authored by the verified account/bot. It writes only the original artifact bytes, excluding the maintainer-action footer and marker, to a *new* relative file under the project root. Missing parent directories are created inside the project -after the comment is verified; symlinks and overwrites are refused. +after the comment is verified; symlinks and overwrites are refused. Run and +step IDs are hashed in the marker so workflow-valid IDs (including dots) +remain valid. Foreign-authored comments are ignored before parsing markers; +malformed markers from the verified author fail explicitly. This implementation +does not provide handle-anchored writes on Windows, so `fetch-artifact` fails +closed there rather than writing outside the project. + `checkout-pr` fetches GitHub's `pull//head` ref, verifies its SHA matches the API's PR head, and checks out that commit detached. Local changes that prevent checkout cause the step to fail. diff --git a/extensions/github/README.md b/extensions/github/README.md index ade406599b..b25d2c5fd6 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -1,8 +1,8 @@ # GitHub Integration Extension -This bundled, **opt-in** extension is the home for Spec Kit's GitHub *platform* functionality. Today it provides one command, `speckit.github.taskstoissues`, which turns a feature's `tasks.md` into dependency-ordered GitHub issues. +This bundled, **opt-in** extension owns Spec Kit's GitHub *agent command* functionality. Today it provides one command, `speckit.github.taskstoissues`, which turns a feature's `tasks.md` into dependency-ordered GitHub issues. The separate built-in [`github` workflow step](../../docs/reference/workflows.md#github-step) is a deterministic workflow building block for comments, artifacts, and PR checkout: registering the step does not install this extension or invoke the step automatically. A workflow must explicitly select `type: github` to run it. -> NOTE: `git` and `github` are deliberately separate domains. The [`git` extension](../git/README.md) owns local version-control workflow (feature branches, commits, remote detection); this extension owns interactions with the GitHub platform itself. +> NOTE: `git` and `github` are deliberately separate domains. The [`git` extension](../git/README.md) owns local version-control workflow (feature branches, commits, remote detection); this extension owns its GitHub agent command, not the workflow engine's built-in step. ## Why an extension? diff --git a/src/specify_cli/workflows/step/github/__init__.py b/src/specify_cli/workflows/step/github/__init__.py index 2d7760bc6d..058144d000 100644 --- a/src/specify_cli/workflows/step/github/__init__.py +++ b/src/specify_cli/workflows/step/github/__init__.py @@ -20,7 +20,7 @@ ) _MARKER = re.compile( r"\Z" ) _MARKER_PREFIX = "") viewer = self._viewer(root, repo) existing = [] for comment in _comments(root, issue_url): + if _author(comment) != viewer: + continue marked = _marked(comment) if marked and marked[2:4] == (run_id, step_id): existing.append((comment, marked)) @@ -356,9 +353,9 @@ def _comment(self, config: dict[str, Any], context: StepContext, root: Path, raise ValueError("Multiple comments exist for this run and step") if existing: comment, marked = existing[0] - if marked[0] != body or marked[1] != artifact or _author(comment) != viewer: + if marked[0] != body or marked[1] != artifact: raise ValueError( - "Existing run/step comment differs or is not owned by the authenticated user" + "Existing run/step comment differs from current content or artifact" ) comment_id = comment.get("id") else: @@ -380,6 +377,8 @@ def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, viewer = self._viewer(root, repo) matches = [] for comment in _comments(root, issue_url): + if _author(comment) != viewer: + continue marked = _marked(comment) if marked and marked[1] == config["artifact"]: matches.append((comment, marked[4])) @@ -389,8 +388,6 @@ def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, f"found {len(matches)}" ) comment, body = matches[0] - if _author(comment) != viewer: - raise ValueError("GitHub artifact was not posted by the authenticated user") comment_id = comment.get("id") if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: raise ValueError("GitHub artifact has no valid comment ID") diff --git a/tests/workflows/test_github_step.py b/tests/workflows/test_github_step.py index fa25019cf7..e155d97ea1 100644 --- a/tests/workflows/test_github_step.py +++ b/tests/workflows/test_github_step.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import pytest @@ -98,6 +99,64 @@ def test_comment_retry_is_idempotent_and_changed_content_fails(context, api): assert len(comments) == 1 +def test_valid_punctuated_step_id_posts_and_retries(context, api): + comments, _ = api + definition = config(id="publish.plan", body="Plan") + assert get_step_type("github").validate(definition) == [] + step = get_step_type("github") + assert step.execute(definition, context).status == StepStatus.COMPLETED + assert step.execute(definition, context).status == StepStatus.COMPLETED + assert len(comments) == 1 + workflow = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: punctuated-step + name: Punctuated step + version: "1.0.0" +steps: + - id: publish.plan + type: github + operation: comment + target: issue + number: 12 + body: Plan +""") + engine = WorkflowEngine(Path(context.project_root)) + assert engine.validate(workflow) == [] + assert engine.execute(workflow, run_id="new-run").status == RunStatus.COMPLETED + assert len(comments) == 2 + + +def test_foreign_markers_cannot_block_post_or_fetch(context, api): + comments, _ = api + step = get_step_type("github") + definition = config(body="Original", artifact="report") + assert step.execute(definition, context).status == StepStatus.COMPLETED + comments.append({"id": 2, "body": "Bad\n\n") diff --git a/tests/workflows/test_github_step.py b/tests/workflows/test_github_step.py index e155d97ea1..9d19d2347b 100644 --- a/tests/workflows/test_github_step.py +++ b/tests/workflows/test_github_step.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from textwrap import indent from types import SimpleNamespace import pytest @@ -321,6 +322,120 @@ def test_engine_executes_github_yaml_without_shell(context, api): assert len(comments) == 1 +@pytest.mark.parametrize("workers", [1, 3]) +@pytest.mark.parametrize("nested", [False, True]) +def test_github_comment_fan_out_retries_per_index(context, api, workers, nested): + comments, calls = api + if nested: + template = """id: branch +type: if +condition: true +then: + - id: publish + type: github + operation: comment + target: issue + number: 12 + body: "same content" +""" + else: + template = """id: publish +type: github +operation: comment +target: issue +number: 12 +body: "same content" +""" + workflow = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: fan-out-github + name: Fan-out GitHub + version: "1.0.0" +steps: + - id: fan + type: fan-out + items: "{{ ['same', 'same', 'same'] }}" + max_concurrency: WORKERS + step: +TEMPLATE +""".replace("WORKERS", str(workers)).replace("TEMPLATE", indent(template, " ").rstrip())) + engine = WorkflowEngine(Path(context.project_root)) + assert engine.validate(workflow) == [] + assert engine.execute(workflow, run_id="fan-run").status == RunStatus.COMPLETED + assert len(comments) == 3 + assert engine.execute(workflow, run_id="fan-run").status == RunStatus.COMPLETED + assert len(comments) == 3 + assert len([method for method, _ in calls if method == "POST"]) == 3 + + +def test_github_fetch_artifact_fan_out_writes_distinct_paths(context, api): + root = Path(context.project_root) + step = get_step_type("github") + assert step.execute(config(body="Shared", artifact="report"), context).status == StepStatus.COMPLETED + workflow = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: fetch-fan-out + name: Fetch fan-out + version: "1.0.0" +steps: + - id: fetch-all + type: fan-out + items: "{{ ['one', 'two'] }}" + max_concurrency: 2 + step: + id: fetch + type: github + operation: fetch-artifact + target: issue + number: 12 + artifact: report + write_to: "artifacts/{{ item }}.md" +""") + engine = WorkflowEngine(root) + assert engine.validate(workflow) == [] + assert engine.execute(workflow).status == RunStatus.COMPLETED + assert (root / "artifacts" / "one.md").read_text() == "Shared" + assert (root / "artifacts" / "two.md").read_text() == "Shared" + + +@pytest.mark.parametrize("workers,expected", [(1, RunStatus.COMPLETED), (2, RunStatus.FAILED)]) +def test_checkout_pr_fan_out_requires_sequential_worktree(context, api, monkeypatch, + workers, expected): + calls = [] + + def fake_run(args, root, *, input_text=None): + calls.append(args) + return "a" * 40 if args[:2] == ["git", "rev-parse"] else "" + + monkeypatch.setattr(github, "_run", fake_run) + workflow = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: checkout-fan-out + name: Checkout fan-out + version: "1.0.0" +steps: + - id: checkouts + type: fan-out + items: "{{ [7, 7] }}" + max_concurrency: WORKERS + step: + id: checkout + type: github + operation: checkout-pr + number: "{{ item }}" +""".replace("WORKERS", str(workers))) + state = WorkflowEngine(Path(context.project_root)).execute(workflow) + assert state.status == expected + if workers == 1: + assert len([call for call in calls if call[:2] == ["git", "fetch"]]) == 2 + else: + assert not calls + assert "share one working tree" in state.error + + def test_pull_request_comment_uses_issue_comment_endpoint(context, api): comments, calls = api definition = config(body="PR feedback", target="pull_request", number=7) @@ -400,7 +515,7 @@ def test_bad_identifiers_and_missing_artifact_fail_without_post(context, api): assert not any(method == "POST" for method, _ in calls) -def test_comment_rejects_symlinked_source_and_fan_out(context, api, tmp_path): +def test_comment_rejects_symlinked_source_inside_fan_out(context, api, tmp_path): root = Path(context.project_root) (root / "link.md").symlink_to(tmp_path.parent / "outside.md") step = get_step_type("github") @@ -408,9 +523,9 @@ def test_comment_rejects_symlinked_source_and_fan_out(context, api, tmp_path): assert result.status == StepStatus.FAILED assert "Symlinked" in result.error context.inside_fan_out = True + context.fan_out_key = "fan:post:0" result = step.execute(config(body="hi"), context) - assert result.status == StepStatus.FAILED - assert "fan-out" in result.error + assert result.status == StepStatus.COMPLETED def test_api_error_fails_without_success_shaped_output(context, api, monkeypatch): diff --git a/workflows/README.md b/workflows/README.md index ccca55aee9..b55ff34795 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -85,7 +85,7 @@ The bundled `speckit` workflow only declares `spec` (and optional ## Step Types -Workflows support 12 built-in step types: +Workflows support 13 built-in step types: ### Command Steps (default) @@ -172,6 +172,14 @@ killed and the step fails; it must be a positive number and defaults to `300` (five minutes) when omitted. Raise it for long-running gates such as full builds, linter aggregators, or integration-test targets. +### GitHub Steps + +The built-in `github` step posts comments, retrieves workflow-posted artifacts, +or checks out a PR head when a workflow explicitly uses `type: github`. It +does not install or invoke the opt-in GitHub agent-command extension. See the +[GitHub step reference](../docs/reference/workflows.md#github-step) for the +operations, credentials, retry behavior, and fan-out constraints. + ### Init Steps Bootstrap a project the same way `specify init` does — scaffolding From f37ba1d49467bc0746eab4926bf19693c10a9001 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:47:22 -0500 Subject: [PATCH 06/10] Limit built-in GitHub workflow step to explicit labels Remove comment, artifact, and PR checkout operations from the first release. Keep retries idempotent by checking existing issue or PR labels before adding the requested label, and document the smaller contract. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec94c538-a8b2-4c39-81fc-3ba5f0a59ce6 --- docs/reference/workflows.md | 81 +- extensions/github/README.md | 2 +- src/specify_cli/workflows/base.py | 6 - src/specify_cli/workflows/engine.py | 14 - .../workflows/step/github/__init__.py | 427 +++-------- tests/workflows/test_github_step.py | 714 +++++------------- workflows/ARCHITECTURE.md | 2 +- workflows/README.md | 10 +- 8 files changed, 296 insertions(+), 960 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 2a8ff34d27..eb6fb322c2 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -570,7 +570,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | | `prompt` | Send an arbitrary prompt to the AI coding agent | | `shell` | Execute a shell command and capture output | -| `github` | Post/retrieve GitHub comments or check out a PR | +| `github` | Add an explicit issue or pull-request label | | `init` | Bootstrap a project (like `specify init`) | | `slot` | Named workflow slot; skipped when unfilled | | `gate` | Pause for human approval before continuing | @@ -586,75 +586,32 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta ### GitHub step `type: github` uses the installed `gh` CLI and its active authentication for a -repository whose `origin` points to `github.com`. The token needs read access -for PRs/issues, and issue-comment write access only for `comment`. The step -does not request or elevate permissions. It does not interpret issue/comment -contents as workflow instructions or apply labels. GitHub operations require -network access; tests should mock the API rather than post live comments. -In GitHub Actions, a repository-scoped `GITHUB_TOKEN` is supported: when -`GITHUB_ACTIONS=true` and `GITHUB_REPOSITORY` matches `origin`, the step -verifies that the active credential is an installation token restricted to -that repository, then checks the author against `github-actions[bot]`. -User tokens (including user tokens used inside Actions) use `GET /user` for -author verification instead. A non-Actions app token cannot post as -`github-actions[bot]`; an unexpected post author fails the step. +repository whose `origin` points to `github.com`. The active token needs +permission to read the issue or pull request and to write its labels (for +example, `issues: write` for issues or `pull-requests: write` for PRs when +using `GITHUB_TOKEN` in Actions). The step uses only the existing token: it +does not request or elevate permissions. It does not inspect issue contents +for instructions or make a maintainer decision. ```yaml -- id: publish-plan +- id: mark-ready type: github - operation: comment + operation: add-label target: issue number: "{{ inputs.issue_number }}" - body_file: docs/plan.md - artifact: reviewed-plan - maintainer_action: - summary: "Review the plan before scheduling implementation." - possible_labels: [ready-for-review] - -- id: retrieve-plan - type: github - operation: fetch-artifact - target: issue - number: "{{ inputs.issue_number }}" - artifact: reviewed-plan - write_to: docs/retrieved-plan.md - -- id: checkout - type: github - operation: checkout-pr - number: "{{ inputs.pr_number }}" + label: ready-for-review ``` -For `comment`, supply exactly one of `body` (text), `body_file` (UTF-8 file), -or `body_files` (non-empty list of UTF-8 files joined with two newlines). `target` is `issue` or `pull_request`; `number` must resolve to a positive -integer. `artifact` is an optional stable alphanumeric identifier (hyphens -and underscores allowed), required by `fetch-artifact`. `maintainer_action` -adds a **proposal only** section with `summary` and `possible_labels` to the -comment; it never changes GitHub labels. The posted comment includes a -digest-bearing marker tying it to the workflow run and step. Re-running the -same step in the same run reuses an identical comment rather than posting a -duplicate; changed content or a changed author fails instead. `fetch-artifact` -requires exactly one intact, previously marked comment authored by the -verified account/bot. It writes only the original artifact bytes, excluding -the maintainer-action footer and marker, to a *new* relative file under the -project root. Missing parent directories are created inside the project -after the comment is verified; symlinks and overwrites are refused. Run and -step IDs are hashed in the marker so workflow-valid IDs (including dots) -remain valid. Foreign-authored comments are ignored before parsing markers; -malformed markers from the verified author fail explicitly. This implementation -does not provide handle-anchored writes on Windows, so `fetch-artifact` fails -closed there rather than writing outside the project. - -`checkout-pr` fetches GitHub's `pull//head` ref, verifies its SHA -matches the API's PR head, and checks out that commit detached. Local changes -that prevent checkout cause the step to fail. -GitHub comments in `fan-out` (including nested branches) use the item's -parent/step/index path in their retry identity, so identical items still create -distinct comments and retry without duplicates. Concurrent fan-out comments -operate on distinct markers. Artifact retrieval in fan-out needs a distinct -`write_to` path per item; concurrent `checkout-pr` fails explicitly because -items share a working tree (use sequential fan-out for checkout). +integer. `label` is one explicit label name (up to 50 characters) and must +already exist in the repository. Both values may use workflow expressions. +The step checks the target type and its existing labels, skips the write when +the label is already present, and verifies GitHub's response after adding it. +The output includes `repository`, `target`, `number`, `label`, and `added` +(`true` only when a label was added). Repeated execution and fan-out items +are safe to retry: an already-present label is left unchanged. This first +version does not post comments, restore artifacts, check out PRs, or infer +which label a maintainer would choose. ### Custom step packages diff --git a/extensions/github/README.md b/extensions/github/README.md index b25d2c5fd6..06e38dd67b 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -1,6 +1,6 @@ # GitHub Integration Extension -This bundled, **opt-in** extension owns Spec Kit's GitHub *agent command* functionality. Today it provides one command, `speckit.github.taskstoissues`, which turns a feature's `tasks.md` into dependency-ordered GitHub issues. The separate built-in [`github` workflow step](../../docs/reference/workflows.md#github-step) is a deterministic workflow building block for comments, artifacts, and PR checkout: registering the step does not install this extension or invoke the step automatically. A workflow must explicitly select `type: github` to run it. +This bundled, **opt-in** extension owns Spec Kit's GitHub *agent command* functionality. Today it provides one command, `speckit.github.taskstoissues`, which turns a feature's `tasks.md` into dependency-ordered GitHub issues. The separate built-in [`github` workflow step](../../docs/reference/workflows.md#github-step) adds an explicitly configured issue or pull-request label: registering the step does not install this extension or invoke the step automatically. A workflow must explicitly select `type: github` to run it. > NOTE: `git` and `github` are deliberately separate domains. The [`git` extension](../git/README.md) owns local version-control workflow (feature branches, commits, remote detection); this extension owns its GitHub agent command, not the workflow engine's built-in step. diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py index d61ccb1938..59d8319d4d 100644 --- a/src/specify_cli/workflows/base.py +++ b/src/specify_cli/workflows/base.py @@ -59,12 +59,6 @@ class StepContext: #: Whether the current step is executing inside a fan-out template. inside_fan_out: bool = False - #: Stable fan-out item path (including parent and item index), for retries. - fan_out_key: str | None = None - - #: Number of concurrent workers in the current fan-out. - fan_out_concurrency: int = 1 - #: Fan-in aggregated results (set only for fan-in steps). fan_in: dict[str, Any] = field(default_factory=dict) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index c7aa3d897d..d7ad0fb857 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -1536,25 +1536,16 @@ def run_item(idx: int, item_ctx: StepContext) -> Any: results: list[Any] = [] previous_item = context.item previous_inside_fan_out = context.inside_fan_out - previous_fan_out_key = context.fan_out_key - previous_fan_out_concurrency = context.fan_out_concurrency context.inside_fan_out = True try: for item_idx, item_val in enumerate(items): context.item = item_val - item_key = item_id(item_idx) - context.fan_out_key = ( - f"{previous_fan_out_key}:{item_key}" - if previous_fan_out_key else item_key - ) results.append(run_item(item_idx, context)) if state.status in halting: break finally: context.item = previous_item context.inside_fan_out = previous_inside_fan_out - context.fan_out_key = previous_fan_out_key - context.fan_out_concurrency = previous_fan_out_concurrency return results # Concurrent path — bounded sliding window; results assembled in item order. @@ -1571,11 +1562,6 @@ def run_isolated(idx: int) -> Any: context, item=items[idx], inside_fan_out=True, - fan_out_concurrency=max(context.fan_out_concurrency, workers), - fan_out_key=( - f"{context.fan_out_key}:{item_id(idx)}" - if context.fan_out_key else item_id(idx) - ), ), ) diff --git a/src/specify_cli/workflows/step/github/__init__.py b/src/specify_cli/workflows/step/github/__init__.py index 31a522f40e..39439b229a 100644 --- a/src/specify_cli/workflows/step/github/__init__.py +++ b/src/specify_cli/workflows/step/github/__init__.py @@ -1,10 +1,8 @@ -"""GitHub workflow step: post comments, retrieve posted artifacts, or check out a PR.""" +"""GitHub workflow step: add an explicit label to an issue or pull request.""" from __future__ import annotations -import hashlib import json -import os import re import subprocess from pathlib import Path @@ -13,121 +11,53 @@ from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus from specify_cli.workflows.expressions import evaluate_expression -_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,99}\Z") _NUMBER = re.compile(r"[1-9][0-9]*\Z") _ORIGIN = re.compile( r"(?:https://github\.com/|git@github\.com:)([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?\Z" ) -_MARKER = re.compile( - r"\Z" -) -_MARKER_PREFIX = "") - viewer = self._viewer(root, repo) - existing = [] - for comment in _comments(root, issue_url): - if _author(comment) != viewer: - continue - marked = _marked(comment) - if marked and marked[2:4] == (run_id, step_id): - existing.append((comment, marked)) - if len(existing) > 1: - raise ValueError("Multiple comments exist for this run and step") - if existing: - comment, marked = existing[0] - if marked[0] != body or marked[1] != artifact: + existing = _label_names(resource.get("labels")) + output = { + "repository": repository, + "target": target, + "number": number, + "label": label, + "added": False, + } + if label.casefold() in existing: + return StepResult(output=output) + labels = _api(root, f"{url}/issues/{number}/labels", labels=[label]) + if label.casefold() not in _label_names(labels): raise ValueError( - "Existing run/step comment differs from current content or artifact" + f"GitHub did not apply label {label!r} to {target} #{number}" ) - comment_id = comment.get("id") - else: - posted = _api(root, f"{issue_url}/comments", method="POST", data={"body": marked_body}) - if not isinstance(posted, dict): - raise ValueError("GitHub returned an invalid posted comment") - if _author(posted) != viewer: - raise ValueError("Posted comment author does not match the authenticated identity") - comment_id = posted.get("id") - if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: - raise ValueError("GitHub comment has no valid ID") - return StepResult(output={"repository": repo, "number": number, "comment_id": comment_id, - "artifact": artifact if artifact != "-" else None}) - - def _fetch(self, config: dict[str, Any], context: StepContext, root: Path, - repo: str, issue_url: str) -> StepResult: - name = _string(_resolved(config["write_to"], context), "write_to") - destination = _project_path(root, name, writing=True) - viewer = self._viewer(root, repo) - matches = [] - for comment in _comments(root, issue_url): - if _author(comment) != viewer: - continue - marked = _marked(comment) - if marked and marked[1] == config["artifact"]: - matches.append((comment, marked[4])) - if len(matches) != 1: - raise ValueError( - f"Expected exactly one trusted artifact {config['artifact']!r}; " - f"found {len(matches)}" - ) - comment, body = matches[0] - comment_id = comment.get("id") - if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: - raise ValueError("GitHub artifact has no valid comment ID") - try: - _write_artifact(root, destination, body) - except FileExistsError as exc: - raise ValueError(f"Destination already exists: {destination}") from exc - except OSError as exc: - raise ValueError(f"Cannot write artifact: {exc}") from exc - return StepResult(output={"path": str(destination), "comment_id": comment_id}) - - @staticmethod - def _checkout(root: Path, url: str, number: int) -> StepResult: - pr = _api(root, f"{url}/pulls/{number}") - head = pr.get("head") if isinstance(pr, dict) else None - sha = head.get("sha") if isinstance(head, dict) else None - if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{40}", sha): - raise ValueError("GitHub PR has no valid head SHA") - _run(["git", "fetch", "origin", f"pull/{number}/head"], root) - fetched = _run(["git", "rev-parse", "FETCH_HEAD"], root) - if fetched != sha: - raise ValueError( - "Fetched PR head differs from the GitHub PR head; retry after the PR settles" - ) - _run(["git", "-c", "advice.detachedHead=false", "checkout", "--detach", sha], root) - if _run(["git", "rev-parse", "HEAD"], root) != sha: - raise ValueError("Checked-out HEAD differs from the GitHub PR head") - return StepResult(output={"head_sha": sha, "number": number}) + output["added"] = True + return StepResult(output=output) + except (ValueError, OSError) as exc: + return StepResult(status=StepStatus.FAILED, error=f"GitHub step: {exc}") diff --git a/tests/workflows/test_github_step.py b/tests/workflows/test_github_step.py index 9d19d2347b..6f3ba0c5bf 100644 --- a/tests/workflows/test_github_step.py +++ b/tests/workflows/test_github_step.py @@ -1,10 +1,9 @@ -"""Deterministic tests for the built-in GitHub workflow step.""" +"""Tests for the opt-in-by-use built-in GitHub label step (no live API calls).""" from __future__ import annotations +import json from pathlib import Path -from textwrap import indent -from types import SimpleNamespace import pytest @@ -17,589 +16,232 @@ @pytest.fixture def context(tmp_path: Path) -> StepContext: return StepContext( - inputs={"issue": "12", "pr": 7}, - project_root=str(tmp_path), - run_id="run-1", + inputs={"number": 12}, project_root=str(tmp_path), run_id="run-1" ) @pytest.fixture def api(monkeypatch): - comments: list[dict] = [] - calls: list[tuple[str, str]] = [] - - def fake_api(root, endpoint, *, method="GET", data=None, paginated=False): - calls.append((method, endpoint)) - if endpoint.endswith("/user"): - return {"login": "maintainer"} - if endpoint.endswith("/pulls/7"): - return {"number": 7, "head": {"sha": "a" * 40}} + stored: list[str] = [] + calls: list[tuple[str, str, list[str] | None]] = [] + + def fake_api(root, endpoint, *, labels: list[str] | None = None): + calls.append(("POST" if labels is not None else "GET", endpoint, labels)) if endpoint.endswith("/issues/12"): - return {"number": 12} - if endpoint.endswith("/comments?per_page=100"): - assert paginated - return [comments[:]] - if endpoint.endswith("/comments") and method == "POST": - comment = {"id": len(comments) + 1, "body": data["body"], - "user": {"login": "maintainer"}} - comments.append(comment) - return comment - raise AssertionError(f"Unexpected API call: {method} {endpoint}") + return {"number": 12, "labels": [{"name": name} for name in stored]} + if endpoint.endswith("/pulls/12"): + return {"number": 12, "labels": [{"name": name} for name in stored]} + if endpoint.endswith("/issues/12/labels") and labels is not None: + for name in labels: + if name.casefold() not in {existing.casefold() for existing in stored}: + stored.append(name) + return [{"name": name} for name in stored] + raise AssertionError(f"Unexpected API call: {endpoint}") monkeypatch.setattr(github, "_api", fake_api) - monkeypatch.setattr(github, "_identity", lambda root: ( - "owner/repo", "https://api.github.com/repos/owner/repo", - )) - return comments, calls - - -def config(**kwargs): - definition = {"id": "post-plan", "type": "github", "operation": "comment", - "target": "issue", "number": "{{ inputs.issue }}"} - definition.update(kwargs) + monkeypatch.setattr( + github, + "_identity", + lambda root: ("owner/repo", "https://api.github.com/repos/owner/repo"), + ) + return stored, calls + + +def config(**overrides): + definition = { + "id": "label", + "type": "github", + "operation": "add-label", + "target": "issue", + "number": "{{ inputs.number }}", + "label": "ready-for-review", + } + definition.update(overrides) return definition -def test_registered_builtin_and_validation(): +def test_builtin_registered_and_accepts_only_add_label(): step = get_step_type("github") assert step is not None assert "github" in BUILTIN_STEP_TYPES - assert step.validate(config(body="text")) == [] - assert step.validate(config(body_file="plan.md")) == [] - assert step.validate(config(body_files=["plan.md"])) == [] - assert step.validate(config(body="text", body_file="plan.md")) - assert step.validate(config(body_files=[])) - assert step.validate(config(body="text", number=True)) - assert step.validate(config(body="text", target="repository")) - assert step.validate(config(body="text", unexpected="field")) - assert step.validate(config(body="text", maintainer_action={"summary": "x"})) - assert step.validate({"id": "fetch", "type": "github", "operation": "fetch-artifact", - "target": "issue", "number": 12, "write_to": "result.md"}) - assert step.validate({"id": "checkout", "type": "github", "operation": "checkout-pr", - "number": 0}) - - -def test_comment_retry_is_idempotent_and_changed_content_fails(context, api): - comments, calls = api - step = get_step_type("github") - definition = config(body="Plan for {{ inputs.issue }}", artifact="plan", - maintainer_action={"summary": "Review only", - "possible_labels": ["approved"]}) - first = step.execute(definition, context) - again = step.execute(definition, context) - assert first.status == again.status == StepStatus.COMPLETED - assert first.output["comment_id"] == again.output["comment_id"] == 1 - assert len(comments) == 1 - assert "Plan for 12" in comments[0]["body"] - assert "Possible labels: approved" in comments[0]["body"] - assert not any("/labels" in endpoint for _, endpoint in calls) - assert len([method for method, _ in calls if method == "POST"]) == 1 - changed = step.execute(config(body="Different", artifact="plan"), context) - assert changed.status == StepStatus.FAILED - assert "differs" in changed.error - assert len(comments) == 1 - - -def test_valid_punctuated_step_id_posts_and_retries(context, api): - comments, _ = api - definition = config(id="publish.plan", body="Plan") - assert get_step_type("github").validate(definition) == [] - step = get_step_type("github") - assert step.execute(definition, context).status == StepStatus.COMPLETED - assert step.execute(definition, context).status == StepStatus.COMPLETED - assert len(comments) == 1 - workflow = WorkflowDefinition.from_string(""" -schema_version: "1.0" -workflow: - id: punctuated-step - name: Punctuated step - version: "1.0.0" -steps: - - id: publish.plan - type: github - operation: comment - target: issue - number: 12 - body: Plan -""") - engine = WorkflowEngine(Path(context.project_root)) - assert engine.validate(workflow) == [] - assert engine.execute(workflow, run_id="new-run").status == RunStatus.COMPLETED - assert len(comments) == 2 + assert step.validate(config()) == [] + assert step.validate(config(target="pull_request")) == [] + for operation in ("comment", "fetch-artifact", "checkout-pr", "remove-label", None): + assert step.validate(config(operation=operation)) + assert step.validate(config(body="Not supported")) + + +def test_issue_label_added_once_and_retry_skips_write(context, api): + labels, calls = api + step = get_step_type("github") + first = step.execute(config(), context) + retry = step.execute(config(), context) + assert first.status == retry.status == StepStatus.COMPLETED + assert first.output == { + "repository": "owner/repo", + "target": "issue", + "number": 12, + "label": "ready-for-review", + "added": True, + } + assert retry.output == {**first.output, "added": False} + assert labels == ["ready-for-review"] + assert len([method for method, _, _ in calls if method == "POST"]) == 1 + + +def test_pull_request_uses_issue_labels_endpoint(context, api): + labels, calls = api + result = get_step_type("github").execute(config(target="pull_request"), context) + assert result.status == StepStatus.COMPLETED + assert result.output["target"] == "pull_request" + assert labels == ["ready-for-review"] + assert calls[0][1].endswith("/pulls/12") + assert calls[1][1].endswith("/issues/12/labels") -def test_foreign_markers_cannot_block_post_or_fetch(context, api): - comments, _ = api - step = get_step_type("github") - definition = config(body="Original", artifact="report") - assert step.execute(definition, context).status == StepStatus.COMPLETED - comments.append({"id": 2, "body": "Bad\n\n