* refactor: move workflow command handlers to workflows/_commands.py (PR-8/8)
Final PR of the __init__.py split. Moves the workflow command group out of
__init__.py into the existing workflows/ package, completing the domain-dir
layout established in PR-5 (integrations), PR-6 (presets) and PR-7
(extensions).
- New workflows/_commands.py holds the four Typer apps (workflow / catalog /
step / step-catalog), all 25 command handlers, the six workflow-only
helpers (_parse_input_values, _workflow_run_payload, _emit_workflow_json,
_stdout_to_stderr_when, _validate_step_id_or_exit,
_resolve_steps_base_dir_or_exit), and a register(app) entry point.
- workflows is already a package, so no rename is needed; intra-package
imports change from `.workflows.x` to `.x`. The only root-helper dep
(_require_specify_project) is reached through a call-time shim so test
monkeypatching of specify_cli._require_specify_project keeps working.
- __init__.py drops ~1445 lines (2066 -> 621); the workflow group is
re-attached via register(app). Dead `contextlib` import removed.
- tests/test_workflows.py: import the now-relocated _stdout_to_stderr_when
helper from its new home (workflows._commands) instead of the package root.
No behavior change. Full suite green (3847 passed), ruff clean.
* Prevent workflow state writes through symlinked storage
Workflow commands persist run state under .specify/workflows/runs, so the command-local project shim now rejects symlinked workflow storage before any workflow command proceeds. The standalone YAML path uses the same guard because it intentionally bypasses the normal project requirement while still creating workflow state under the current directory.
Constraint: Local YAML workflow runs do not require an existing .specify project directory but still create .specify/workflows/runs state
Rejected: Guard only .specify in the file-source path | .specify/workflows and runs can independently redirect writes
Confidence: high
Scope-risk: narrow
Directive: Keep workflow storage symlink checks centralized before constructing WorkflowEngine
Tested: .venv/bin/python -m pytest tests/test_workflow_run_without_project.py tests/test_workflows.py::TestWorkflowAddSymlinkGuard -v
Tested: .venv/bin/python -m py_compile src/specify_cli/workflows/_commands.py tests/test_workflow_run_without_project.py tests/test_workflows.py
Not-tested: Ruff lint; ruff is not installed in the repo virtualenv
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
* fix(workflows): pass github_hosts allowlist to GHES release asset resolver
workflow add resolved GitHub release download URLs without forwarding the
github_provider_hosts() allowlist, so resolve_github_release_asset_api_url
never treated any host as GHES. This regressed GitHub Enterprise Server
release asset resolution and diverged from presets/extensions, which already
pass github_hosts. Forward github_provider_hosts() at both the direct-URL and
catalog install call sites. The allowlist remains the anti-SSRF gate.
* fix(workflows): reject symlinked/traversal <id> dir on workflow install
Local/URL and catalog installs wrote to .specify/workflows/<id>/workflow.yml
without guarding the <id> segment. A pre-planted symlink at <id> or
<id>/workflow.yml let mkdir+copy/download follow it and write outside the
project root; a non-directory <id> made mkdir raise unhandled.
Add _safe_workflow_id_dir() to reject path traversal, symlinked or
non-directory <id>, and a symlinked workflow.yml leaf before any write.
Fold the catalog branch's existing traversal check into the helper.
* fix(workflows): harden _safe_workflow_id_dir output and leaf checks
- Reorder symlink/non-directory check before resolve() so a symlinked
<id> reports as symlinked instead of misleading "Invalid workflow ID"
- Reject a pre-existing <id>/workflow.yml that is not a file, avoiding an
unhandled IsADirectoryError on later write/copy2
- Escape workflow_id in Rich output to prevent markup injection; escape
the repr (not the raw id) so repr-added backslashes cannot re-expose
brackets, matching extensions/_commands.py hardening
- Add tests for workflow.yml-as-directory and markup-escaped invalid id
* Avoid stale lint failures from config helper imports
Move PyYAML loading into the helpers that read and write agent-context configuration, and replace the broad Any annotation with object. The runtime behavior stays the same while the module no longer exposes top-level imports that can be flagged as unused when CI analyzes a narrower code shape.
* Prevent workflow commands from targeting reserved storage
Workflow install and removal paths are derived from workflow IDs before any catalog download, local copy, or directory deletion. Validate that IDs are single workflow-id path segments and reject names reserved for workflow runtime storage so commands cannot target .specify/workflows/runs or .specify/workflows/steps.
* fix: non-zero exit code when a workflow run ends failed or aborted
workflow run and workflow resume printed Status: failed (or emitted the
--json payload) and exited 0, so scripts and CI could not rely on the
process exit code. Map terminal outcomes: failed|aborted -> 1,
completed|paused -> 0, on both the text and --json paths.
The previous exit-0-on-failed behavior was pinned by
test_workflow_run_failing_yaml_without_project; the pin is updated to
the new contract.
Fixes#2958
* test: portable exit-code step commands + cover resume failed->exit-1
Address review (#2959): replace non-portable run: 'true'/'false' with 'exit 0'/'exit 1' (Windows cmd.exe has no true/false builtins under shell=True), and add an end-to-end 'workflow resume' test asserting a resumed failed run exits non-zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Initial plan
* feat: add --workflow option to init command for post-init workflow execution
* chore: remove unused import in test file
* refactor: allow workflow run without project when given a YAML file path
Instead of adding --workflow to init, make `specify workflow run ./file.yml`
work without requiring a .specify/ project directory. When the source is a
YAML file that exists on disk, cwd is used as the project root. When it's a
workflow ID, the .specify/ project requirement is preserved.
* Handle standalone workflow path edge cases
* Fix USERPROFILE env var portability and docs notation
* Fix workflow YAML path detection to require regular files
* Harden workflow run against unsafe .specify paths
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>