Skip to content

Commit d6fa046

Browse files
markuswondrakMarkusCopilotCopilot
authored
feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
* feat(workflows): add standalone WorkflowResolver and overlay subsystem Implement PR 1 of the workflow-overlays plan: a concrete, standalone WorkflowResolver for downstream workflow extensibility without touching the Preset subsystem. - Add overlay manifest schema (Overlay, OverlayEdit, validate_overlay_yaml) - Add pure-function merge engine (find_step, apply_edit, merge_steps, validate_edits) with recursive anchor search and higher-wins semantics - Add StepListComposer and tiered layer sources (project, installed, base) - Add WorkflowResolver facade with inline HIGHER_WINS priority sorting - Add CLI verbs: workflow overlay add/set-priority/enable/disable/remove/list and workflow resolve <id> - Wire WorkflowEngine.load_workflow through WorkflowResolver - Extend workflow add to copy optional overlays/ subdirectory from local workflow directories - Add comprehensive unit, integration, and security tests Refs: discussion #3473 (#3473) Assisted-by: Kimi (model: opencode-go/kimi-k2.7-code, autonomous) * fix(workflows): reject symlinked overlay directories in layer sources Address PR #3557 review comments r3594064534 and r3594064563: - ProjectOverlaySource.collect now rejects symlinked per-workflow overlay directories (.specify/workflows/overlays/<id>) before iterating - InstalledOverlaySource.collect now rejects symlinked installed overlay directories (.specify/workflows/<id>/overlays) before iterating - workflow_overlay_list catches ValueError from resolver and exits with code 1 instead of crashing on unhandled exceptions - Added .specify/workflows/overlays to _reject_unsafe_workflow_storage chokepoint for defense-in-depth These guards prevent symlinked overlay directories from redirecting auto-loaded overlay YAML to attacker-controlled content outside the project, which could inject executable shell steps into trusted workflows. Refs: PR #3557 review comments r3594064534, r3594064563 Assisted-by: opencode-go/qwen3.7-max (autonomous) * fix(workflows): address Copilot review findings in merge engine - Apply inserts before winning replace to prevent anchor-not-found errors when replace changes step ID (r3594064604) - Track attribution recursively for nested steps in composite inserts/replaces so workflow resolve attributes all child steps correctly (r3594064638) - Add regression tests for both fixes Refs: PR #3557 review discussion Assisted-by: GitHub Copilot (model: qwen3.7-plus, autonomous) * refactor(workflows): simplify overlay architecture to 2-tier Remove installed overlays tier to enforce clean separation of concerns: - workflow add installs workflows only (no overlay copying) - workflow overlay add installs overlays only (project-local) Changes: - Remove InstalledOverlaySource class and all references - Remove overlay-copying logic from _validate_and_install_local() - Update WorkflowResolver to 2-tier: project overlays + base workflow - Fix --priority override timing: apply before validation, not after - Remove tests for installed overlays (no longer applicable) Rationale: If upstream controls both base workflow and shipped overlays, and both get overwritten on bundle update, there's no reason to ship overlays separately. Overlays only make sense when someone other than the base author adds them. Resolves all three review findings from PR #3557: - r3594064677: workflow add no longer copies overlays from all call sites - r3594064705: --priority override now applied before validation - r3594064726: no stale installed overlays (tier removed entirely) Assisted-by: Claude (model: claude-opus-4-7, autonomous) * fix(workflows): harden overlay symlink handling Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(workflows): remove stale installed-overlay references from workflows.md The 2-tier refactor (cc28185) removed the installed-overlay tier entirely, but docs/reference/workflows.md was not updated. This commit addresses all four Cluster 2 findings from the PR review: - workflow add: remove sentence about copying overlays/ subdirectory - How Overlays Work: drop installed-overlay table row and precedence prose; rewrite to 2-tier model (project overlays only, source-order tie-break) - overlay remove: drop trailing sentence about installed overlays - Interaction with Bundles: rewrite to say workflow add installs only workflow.yml; remove installed-overlay discovery language Fixes: r3596368791, r3596368831, r3596368873, r3596368919 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(overlays): detect ancestor-conflict anchors in merge_steps When two overlay edits target anchors that share a parent/descendant relationship (e.g. remove an if-step + insert_after a nested child), merge_steps processed them independently and in dict-insertion order, making the outcome non-deterministic. Add two private helpers to merge.py: - _descendant_ids(step): returns all step IDs nested inside a step dict by delegating to the existing _all_base_step_ids helper on children. - _check_anchor_conflicts(anchors, base_steps): for each targeted anchor finds its descendants and checks whether any other targeted anchor is among them; returns human-readable error strings. Wire _check_anchor_conflicts into merge_steps immediately after edits_by_anchor is built, before any tree mutation occurs. Raises ValueError listing the conflicting anchor pair(s) so overlay authors know exactly what to fix. Add TestMergeStepsAncestorConflicts (6 cases): - remove parent + insert_after child raises ValueError - replace parent + remove child raises ValueError - conflict across multiple overlays raises ValueError - sibling anchors (not ancestor/descendant) pass - single anchor passes - parent targeted but child not targeted passes Closes review comment r3596368746 (PR #3557, round 2, cluster 3). Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(overlays): fix over-broad conflict detection and non-deterministic ID collision Finding 1.1 — _check_anchor_conflicts was rejecting any ancestor/descendant anchor pair, including insert-only edits that are perfectly safe. Only replace/remove on an ancestor can destroy its subtree and make a descendant anchor unresolvable. Change the signature to accept a dict[str, str] (anchor → winning operation) and skip the check for insert_after/insert_before. Finding 1.2 — merge_steps was calling find_step on the already-mutated tree, so a replacement step that reused a base step ID could be accidentally targeted by a later edit group (non-deterministic result depending on dict iteration order). Replace the anchor-group loop with a single-pass _traverse_and_apply that walks the original tree structure and applies edits as each step is encountered. Anchors are never re-looked up in a mutated tree. Design invariant enforced: overlays always apply to the original base tree and cannot target steps introduced by other overlays. Non-remove edits on non-base anchors now raise ValueError early. Also removes apply_edit (no production callers, only tested in isolation) and its test class — the new traversal inlines the same mechanics without the find_step round-trip. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(overlays): reject ID trailing newlines and reuse existing .yaml path Fix two input validation bugs in the overlay layer (Group 2 of copilot review PR #3557): 1. _validate_safe_id in schema.py used re.match() which anchors only at the start of the string, so IDs like 'overlay\n' passed validation and could produce newline-containing file paths. Changed to fullmatch() so the entire string must satisfy the pattern. 2. workflow_overlay_add always wrote <id>.yml without checking whether <id>.yaml already existed. Since the resolver loads both extensions, this created two active layers whose edits applied twice. Now uses the existing _find_overlay_file() to detect a pre-existing file and reuse its path, falling back to .yml only for new overlays. Tests added for both fixes. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(overlays): fix display order inversion and wrap file-read errors Finding group 3 from copilot-review-v2.md: 3.1 — Precedence display inverted (overlays/__init__.py) collect_all_layers used a single-pass sort by (-priority, source_asc), which placed the *losing* equal-priority source first in the display while claiming "highest first". Fix: two-pass stable sort — source descending then priority descending — so the actual winner (last applied by the composer) rises to the top of the display. 3.2 — Unwrapped file-read errors (overlays/layer_sources.py) Only yaml.YAMLError was caught around path.read_text(), so an unreadable or non-UTF-8 overlay produced a raw traceback. Fix: widen the except clause to (yaml.YAMLError, OSError, UnicodeDecodeError), matching the pattern used throughout catalog.py. Tests: - test_workflow_resolve_equal_priority_winner_shown_first: verifies project:zzz (the winner) appears before project:aaa in workflow resolve output when both overlays share the same priority. - tests/workflows/test_overlay_layer_sources.py (new): OSError and non-UTF-8 bytes both produce OverlayLoadError, not raw tracebacks. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: rename misleading overlay test Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove EOF blank line in overlay resolver Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: handle overlay read and enumeration errors Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(overlays): validate resolver workflow IDs Reject unsafe and reserved workflow IDs before overlay or base sources construct paths, preventing traversal through resolver and engine fallback paths. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(overlays): drop _remove_sources_recursively from remove branch In _traverse_and_apply, the remove branch called _remove_sources_recursively to clean up attribution entries for the deleted step. This was inherited from the old apply_edit loop (c70a5d6) where it was needed because the sources dict was queried exhaustively. In the current single-pass design, _build_attribution only traverses the result list, so stale sources entries for removed steps are never read. The cleanup call is therefore unnecessary — and actively harmful when another overlay has replaced a different step with a new step that reuses the same ID: the pop clobbers the replacement's attribution entry, causing workflow resolve to report the surviving step as 'unknown'. Fix: simply remove the _remove_sources_recursively call from the remove branch. Add an attribution assertion to the existing reused-ID regression test to catch this case. Fixes: r3604242050 (Copilot review finding) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * Fix CLI overlay ID validation anchoring Use fullmatch for CLI workflow/overlay ID validation so trailing newlines are rejected consistently with manifest validation. Add regression coverage for newline-suffixed workflow and overlay IDs in overlay set-priority. Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): validate workflow_id in layer sources before path construction ProjectOverlaySource.collect() and BaseWorkflowSource.collect() joined workflow_id directly onto storage paths without validation, enabling path traversal (e.g. '../../outside') when called outside the WorkflowResolver. Add _validate_workflow_id() to layer_sources.py — mirrors the same _SAFE_ID_PATTERN / _RESERVED_WORKFLOW_IDS check used by WorkflowResolver in overlays/__init__.py — and call it at the top of both collect() methods before any path is constructed. Adds parametrised tests covering unsafe IDs and verifying no filesystem access occurs for an invalid ID. Closes review finding r3604772700. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(workflows): align layer source validation with _safe_workflow_id_dir Plan §4.1 requires that Workflow-ID-Validierung, Symlink-/Containment- Prüfungen and Fehlerübersetzung must not diverge between workflow management and the overlay resolver. My previous fix added ID pattern + reserved-name validation to both collect() methods but was missing the containment step and the BaseWorkflowSource directory/file checks that _safe_workflow_id_dir performs. Changes: - Add _ensure_contained_dir(path, root) to layer_sources.py — pure domain mirror of overlays/_commands.py::_ensure_contained_dir that raises OverlayLoadError instead of typer.Exit - ProjectOverlaySource.collect(): replace two inline symlink/dir checks with _ensure_contained_dir(workflow_overlay_dir, self.overlays_dir), adding the missing resolve().relative_to() containment step - BaseWorkflowSource.collect(): add _ensure_contained_dir on the workflow directory, and add workflow.yml symlink check before is_file() The same logic now lives in three places (workflow CLI, overlay CLI, layer sources). The DRY extraction to workflows/_validation.py is deferred to PR 3 per plan §4.1. Tests: add containment and symlink tests for both sources. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(overlays): resolve identity from manifest field, not filename Align overlay identity resolution with the project-wide convention: presets use preset.id, extensions use extension.id, workflows use workflow.id, and workflow steps use step.type_key. Overlays must derive identity from the manifest id field, not the filename. Rewrite _find_overlay_file() to scan all YAML files in the overlay directory and match on the manifest id field, fixing the bug where enable/disable/remove/set-priority failed when filename != manifest id. Closes: PR #3557 discussion r3605010197 Assisted-by: opencode-go/qwen3.7-max (autonomous) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(workflows): make validation behavior consistent across YAML loading paths Address PR #3557 review finding r3607632921: - Wrap yaml.YAMLError → ValueError in from_yaml() and from_string() so malformed YAML matches the documented exception contract - Add except ValueError to workflow_info to handle composition errors cleanly instead of crashing with a raw traceback - Remove validate_workflow() from compose() so the resolver path is parse-only like all other YAML loading mechanisms; callers validate explicitly via engine.validate() - Update test to reflect new behavior: resolve() returns composed definition, caller validates separately Assisted-by: opencode-go/qwen3.7-max (autonomous) * fix(overlays): list disabled overlays in management view Keep disabled overlays visible in workflow overlay list while leaving resolution behavior unchanged. - add an include_disabled opt-in to overlay source/resolver collection - use include_disabled=True for workflow overlay list - add regression tests for list visibility and default filtering Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: align overlay extends and resolver contract Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use atomic write for overlay file updates to prevent hard-link attack Replace in-place write_text() calls in workflow_overlay_add() and _update_overlay_field() with the same mkstemp → write → os.replace() pattern used by the workflow installer (_stage_workflow_file / _commit_workflow_file / _discard_staged_workflow_file). The prior code rejected symlinks and validated path containment, but a hard-linked destination file passes both checks while sharing an inode with an external file. write_text() would then truncate and overwrite that external inode. The atomic staging approach never opens the existing destination for writing, eliminating the hard-link vector. Fixes findings r3608669512 and r3608669517 on PR #3557. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, supervised) * fix(composer): preserve invalid base definition instead of coercing steps to [] When 'steps' is not a list, returning early with the unmodified WorkflowDefinition lets validate_workflow surface the proper error ("'steps' must be a list.") to the caller. The previous silent coercion to [] masked the validation error entirely. Fixes: #3557 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, supervised) * fix: align workflow overlay priority semantics Assisted-by: GitHub Copilot (model: GPT-5.6 Terra, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate overlay priority presentation Assisted-by: GitHub Copilot (model: GPT-5.6 Terra, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: catch OverflowError in normalize_priority for float infinity values YAML values like `priority: .inf` parse to float('inf'), causing int() to raise OverflowError. This broke validate_overlay_yaml()'s 'validation never raises' contract. Adding OverflowError to the except clause makes it fall back to the default priority (10), consistent with other invalid value handling. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Markus <markus@example.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8a5bcc2 commit d6fa046

19 files changed

Lines changed: 4938 additions & 7 deletions

‎docs/reference/workflows.md‎

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,192 @@ specify workflow add <source>
9191
| `--dev` | Install from a local workflow YAML file or directory |
9292
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
9393

94-
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
94+
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
9595

96+
## Workflow Overlays
97+
98+
Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades.
99+
100+
When `specify workflow run <workflow-id>` loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.
101+
102+
### How Overlays Work
103+
104+
An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.
105+
106+
Project overlay files live at:
107+
108+
| Location | Purpose |
109+
| --- | --- |
110+
| `.specify/workflows/overlays/<id>/*.yml` | Project-local customizations |
111+
112+
### Overlay File Format
113+
114+
The recommended edit format uses the operation name as the key and the anchor step id as the value:
115+
116+
```yaml
117+
id: "my-overlay"
118+
extends: "speckit"
119+
priority: 10
120+
enabled: true
121+
edits:
122+
- insert_after: implement
123+
step:
124+
id: run-lint
125+
type: shell
126+
run: "ruff check src/"
127+
128+
- replace: review-spec
129+
step:
130+
id: review-spec
131+
type: gate
132+
message: "Review the generated spec (overlay override)."
133+
options: [approve, reject]
134+
on_reject: abort
135+
```
136+
137+
The explicit form is also supported:
138+
139+
```yaml
140+
edits:
141+
- operation: insert_after
142+
anchor: implement
143+
step:
144+
id: run-lint
145+
type: shell
146+
run: "ruff check src/"
147+
```
148+
149+
#### Fields
150+
151+
| Field | Required | Description |
152+
| --- | --- | --- |
153+
| `id` | yes | Identifier for this overlay. Used in `specify workflow overlay *` commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or `overlays`. |
154+
| `extends` | yes | The workflow id this overlay applies to. Uses the same safe-id format as `id`; `overlays`, `runs`, and `steps` are reserved. |
155+
| `priority` | no | Integer; defaults to `10`. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to `10`. |
156+
| `enabled` | no | Boolean. Defaults to `true`. Disabled overlays are ignored. |
157+
| `edits` | yes | Non-empty list of edit operations. |
158+
159+
#### Edit Operations
160+
161+
| Operation | `step` required | Effect |
162+
| --- | --- | --- |
163+
| `insert_after` | yes | Insert `step` immediately after the anchor step. |
164+
| `insert_before` | yes | Insert `step` immediately before the anchor step. |
165+
| `replace` | yes | Replace the anchor step with `step`. |
166+
| `remove` | no | Remove the anchor step from the list. |
167+
168+
The `anchor` is the `id` of a step in the base workflow. Anchors are resolved recursively inside `then`, `else`, `steps`, `cases.*`, and `default` blocks, so nested base steps can also be targeted. Fan-out templates (`step` inside a `fan-out` step) are **not** valid anchors.
169+
170+
Step ids must not contain `:` — that character is reserved for engine-generated nested ids.
171+
172+
### Overlay CLI Commands
173+
174+
#### Add a Project Overlay
175+
176+
```bash
177+
specify workflow overlay add <path-to-overlay.yml> --priority <n>
178+
```
179+
180+
Validates the overlay file and copies it to `.specify/workflows/overlays/<extends>/<id>.yml`. `--priority` defaults to `10` and overrides the `priority` field in the file.
181+
182+
#### List Overlays
183+
184+
```bash
185+
specify workflow overlay list <workflow-id>
186+
```
187+
188+
Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.
189+
190+
#### Change Priority
191+
192+
```bash
193+
specify workflow overlay set-priority <workflow-id> <overlay-id> <n>
194+
```
195+
196+
#### Enable or Disable
197+
198+
```bash
199+
specify workflow overlay disable <workflow-id> <overlay-id>
200+
specify workflow overlay enable <workflow-id> <overlay-id>
201+
```
202+
203+
#### Remove
204+
205+
```bash
206+
specify workflow overlay remove <workflow-id> <overlay-id>
207+
```
208+
209+
Removes the project overlay file.
210+
211+
#### Inspect the Composed Workflow
212+
213+
```bash
214+
specify workflow resolve <workflow-id>
215+
```
216+
217+
Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.
218+
219+
### Example: Adding Automated Linting after Implementation
220+
221+
Given the built-in `speckit` workflow, create `project-overlay.yml`:
222+
223+
```yaml
224+
id: "add-lint"
225+
extends: "speckit"
226+
priority: 10
227+
edits:
228+
- insert_after: implement
229+
step:
230+
id: run-lint
231+
type: shell
232+
run: "ruff check src/"
233+
```
234+
235+
Install it:
236+
237+
```bash
238+
specify workflow overlay add project-overlay.yml --priority 10
239+
```
240+
241+
Run the workflow:
242+
243+
```bash
244+
specify workflow run speckit -i spec="Build a kanban board"
245+
```
246+
247+
The composed workflow will now run the full SDD cycle and execute `ruff check src/` automatically after the `implement` step.
248+
249+
### Example: Replacing a Gate
250+
251+
```yaml
252+
id: "skip-plan-review"
253+
extends: "speckit"
254+
priority: 5
255+
edits:
256+
- replace: review-plan
257+
step:
258+
id: review-plan
259+
type: command
260+
command: speckit.plan
261+
input:
262+
args: "{{ inputs.spec }}"
263+
```
264+
265+
Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
266+
267+
### Interaction with Bundles and Updates
268+
269+
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
270+
271+
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
272+
273+
### Limitations
274+
275+
- Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, `requires`) or expression logic.
276+
- Fan-out templates cannot be used as anchors.
277+
- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
278+
- Overlays cannot target steps added by other overlays.
279+
- Overlays cannot add new inputs or change the input schema of the base workflow.
96280
## Update Workflows
97281

98282
```bash

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
136136
return default
137137
try:
138138
priority = int(value)
139-
except (TypeError, ValueError):
139+
except (TypeError, ValueError, OverflowError):
140140
return default
141141
return priority if priority >= 1 else default
142142

‎src/specify_cli/workflows/_commands.py‎

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@
4949
)
5050
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
5151

52+
workflow_overlay_app = typer.Typer(
53+
name="overlay",
54+
help="Manage workflow overlays",
55+
add_completion=False,
56+
)
57+
workflow_app.add_typer(workflow_overlay_app, name="overlay")
58+
5259

5360
def _error_console(json_output: bool):
5461
"""Console for error text: stderr under ``--json`` so the JSON stdout
@@ -192,6 +199,10 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
192199
project_root / ".specify" / "workflows" / "runs",
193200
".specify/workflows/runs",
194201
)
202+
_reject_unsafe_dir(
203+
project_root / ".specify" / "workflows" / "overlays",
204+
".specify/workflows/overlays",
205+
)
195206

196207

197208
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
@@ -366,7 +377,7 @@ def ownership_for(candidate: Path) -> tuple[Path, str] | None:
366377

367378

368379
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
369-
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"runs", "steps"})
380+
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
370381

371382

372383
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
@@ -2386,6 +2397,9 @@ def workflow_info(
23862397
# Local workflow definition not found on disk; fall back to
23872398
# catalog/registry lookup below.
23882399
pass
2400+
except ValueError as exc:
2401+
console.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}")
2402+
raise typer.Exit(1)
23892403

23902404
if definition:
23912405
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
@@ -3152,6 +3166,102 @@ def workflow_step_catalog_remove(
31523166
console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed")
31533167

31543168

3169+
@workflow_overlay_app.command("add")
3170+
def workflow_overlay_add_cmd(
3171+
source: Path = typer.Argument(..., help="Path to overlay YAML file"),
3172+
priority: int = typer.Option(
3173+
10,
3174+
"--priority",
3175+
help="Resolution priority (lower = higher precedence, default 10)",
3176+
),
3177+
):
3178+
"""Add a project-local overlay for a workflow."""
3179+
from .overlays._commands import workflow_overlay_add
3180+
3181+
project_root = _require_specify_project()
3182+
if workflow_overlay_add(project_root, source, priority) is None:
3183+
raise typer.Exit(1)
3184+
3185+
3186+
@workflow_overlay_app.command("set-priority")
3187+
def workflow_overlay_set_priority_cmd(
3188+
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
3189+
overlay_id: str = typer.Argument(..., help="Overlay ID"),
3190+
priority: int = typer.Argument(
3191+
..., help="New priority (lower = higher precedence)"
3192+
),
3193+
):
3194+
"""Set the priority of a project-local overlay."""
3195+
from .overlays._commands import workflow_overlay_set_priority
3196+
3197+
project_root = _require_specify_project()
3198+
if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority):
3199+
raise typer.Exit(1)
3200+
3201+
3202+
@workflow_overlay_app.command("enable")
3203+
def workflow_overlay_enable_cmd(
3204+
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
3205+
overlay_id: str = typer.Argument(..., help="Overlay ID"),
3206+
):
3207+
"""Enable a project-local overlay."""
3208+
from .overlays._commands import workflow_overlay_enable
3209+
3210+
project_root = _require_specify_project()
3211+
if not workflow_overlay_enable(project_root, workflow_id, overlay_id):
3212+
raise typer.Exit(1)
3213+
3214+
3215+
@workflow_overlay_app.command("disable")
3216+
def workflow_overlay_disable_cmd(
3217+
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
3218+
overlay_id: str = typer.Argument(..., help="Overlay ID"),
3219+
):
3220+
"""Disable a project-local overlay."""
3221+
from .overlays._commands import workflow_overlay_disable
3222+
3223+
project_root = _require_specify_project()
3224+
if not workflow_overlay_disable(project_root, workflow_id, overlay_id):
3225+
raise typer.Exit(1)
3226+
3227+
3228+
@workflow_overlay_app.command("remove")
3229+
def workflow_overlay_remove_cmd(
3230+
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
3231+
overlay_id: str = typer.Argument(..., help="Overlay ID"),
3232+
):
3233+
"""Remove a project-local overlay."""
3234+
from .overlays._commands import workflow_overlay_remove
3235+
3236+
project_root = _require_specify_project()
3237+
if not workflow_overlay_remove(project_root, workflow_id, overlay_id):
3238+
raise typer.Exit(1)
3239+
3240+
3241+
@workflow_overlay_app.command("list")
3242+
def workflow_overlay_list_cmd(
3243+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
3244+
):
3245+
"""List overlays for a workflow."""
3246+
from .overlays._commands import workflow_overlay_list
3247+
3248+
project_root = _require_specify_project()
3249+
if workflow_overlay_list(project_root, workflow_id) is None:
3250+
raise typer.Exit(1)
3251+
3252+
3253+
@workflow_app.command("resolve")
3254+
def workflow_resolve_cmd(
3255+
workflow_id: str = typer.Argument(..., help="Workflow ID to resolve"),
3256+
):
3257+
"""Show layer attribution for a resolved workflow."""
3258+
from .overlays._commands import workflow_resolve
3259+
3260+
project_root = _require_specify_project()
3261+
if workflow_resolve(project_root, workflow_id) is None:
3262+
raise typer.Exit(1)
3263+
3264+
31553265
def register(app: typer.Typer) -> None:
31563266
"""Attach the workflow command group to the root Typer app."""
31573267
app.add_typer(workflow_app, name="workflow")

0 commit comments

Comments
 (0)