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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions docs/src/content/docs/specs/model-alias-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ This document is governed by the GitHub Agentic Workflows project specifications
13. [Safeguards](#13-safeguards)
14. [Sync Notes](#14-sync-notes)
15. [Norms](#15-norms)
16. [Appendices](#appendices)
17. [References](#references)
18. [Change Log](#change-log)
16. [Operations](#16-operations)
17. [Appendices](#appendices)
18. [References](#references)
19. [Change Log](#change-log)

---

Expand Down Expand Up @@ -769,7 +770,7 @@ This limit prevents runaway resolution in pathological alias maps and bounds the

This section maps normative sections of this specification to the implementation files in `pkg/workflow/` that realize each requirement. Use this mapping to identify which files must be reviewed or updated when specification sections change.

**Last verified**: 2026-06-01
**Last verified**: 2026-07-08

### §4–§6 Parsing and Parameter Encoding

Expand Down Expand Up @@ -844,6 +845,18 @@ This section provides a normative reference table for all MUST/SHALL requirement

---

## 16. Operations

This section defines the caller-facing runtime lifecycle for resolving a model alias at compile time.

1. **Invoke Resolution**: The implementation MUST parse the caller-provided model identifier into base identifier and parameter map before attempting alias expansion.
2. **Initialize Guards**: The implementation MUST initialize a depth counter (starting at 0) and a per-call visited-set before traversing alias references.
3. **Resolve Recursively**: For each alias hop, the implementation MUST increment depth, merge parameters per §8.3, and reject cycles when the current alias key already exists in the visited-set (before adding it for this hop).
4. **Enforce Limits**: The implementation MUST fail with a resolution error when depth exceeds the safeguard ceiling defined by R-MAF-S001.
5. **Return Deterministically**: The implementation MUST return the first conforming concrete model selected by §8 ordering rules, or a descriptive error when no candidate can be resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The new §16 Operations section is positioned after §15 Norms (the normative reference table) but references requirement IDs defined in §13 (e.g., R-MAF-S001). Placing the Operations section after the norms table and appendices is unusual — readers expect lifecycle/operational steps to come before the reference tables, not after. Also, step 3 says "reject cycles when the next alias key already exists in the visited-set" but the condition should be the current key, not the next key — misreading could cause implementors to skip the last-hop cycle check.

💡 Suggestions
  1. Move §16 Operations to sit between §13 Safeguards and §14 Sync Notes to match the natural reading flow.
  2. Clarify step 3: "reject cycles when the current alias key already exists in the visited-set (before adding it)".

@copilot please address this.


---

## Appendices

### Appendix A: Complete Resolution Example
Expand Down Expand Up @@ -966,7 +979,7 @@ Model parameters are compile-time configuration values and are not derived from
### Version 1.2.0 (Draft)

- **Added**: §13 Safeguards covering max alias-chain depth (R-MAF-S001), UTF-8 validity requirements (R-MAF-S002, R-MAF-S003), out-of-range `effort`/`temperature` handling (R-MAF-S004, R-MAF-S005), and corrupt builtin-alias-map behavior (R-MAF-S006, R-MAF-S007).
- **Added**: §14 Sync Notes mapping §§4–11 to implementation files in `pkg/workflow/` with loop-detection test references (last verified 2026-06-01).
- **Added**: §14 Sync Notes mapping §§4–11 to implementation files in `pkg/workflow/` with loop-detection test references (last verified 2026-07-08).
- **Added**: §15 Norms reference table (`V-MAF-*` and `R-MAF-S*` IDs) for all MUST/SHALL requirements in §§4–13.
- **Updated**: Table of Contents to include §§13–15.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ The remove lifecycle uninstalls a previously installed package by deleting its i

**R-PKG-R004**: After removal, if the target workflow directory is empty, the implementation MAY remove the empty directory. The implementation MUST NOT remove non-empty directories.

> **Note**: Package-installed documentation files (for example `README.md`) are within the scope of R-PKG-R001 and follow modified-file protection in R-PKG-R002.

## 6. Documentation

Package documentation is `README.md` in the package root.
Expand Down Expand Up @@ -343,7 +345,7 @@ This section provides a normative reference table for all MUST/SHALL requirement

This section maps normative sections of this specification to the implementation files in `pkg/cli/` and `pkg/parser/` that realize each requirement.

**Last verified**: 2026-06-01
**Last verified**: 2026-07-08

### §4 Manifest Format — Implementation Mapping

Expand All @@ -361,6 +363,7 @@ This section maps normative sections of this specification to the implementation
|---|---|---|
| §5 File resolution | Resolving `files` list vs. auto-discovery under `workflows/` and `.github/workflows/` | `pkg/cli/add_package_manifest.go` (`resolveRepositoryPackage`) |
| §5 Install ordering | Download → compile → write per-file sequencing | `pkg/cli/add_package_manifest.go`, `pkg/cli/add_command.go` |
| §5 Install rollback on write failure (R-PKG-003) | Write-failure abort plus rollback of files written earlier in the same add operation | `pkg/cli/add_command.go` (`addWorkflowsWithTracking`), `pkg/cli/add_command_test.go` (`TestAddWorkflowsWithTracking_RollsBackWrittenFilesOnWriteFailure`) |

### §4.2 and §4.3 Verification Findings

Expand Down
59 changes: 57 additions & 2 deletions docs/src/content/docs/specs/safe-outputs-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,57 @@ For `x-safe-outputs-target-requirements["*"]`:
- If no listed `anyOf` field is present, the request MUST be rejected with an MCP validation error.
- When `target` is not `"*"`, implementations MUST follow each type's normal **Operational Semantics** context resolution behavior; this metadata MUST NOT add additional required runtime identifier fields.

### 7.0.2 Handler Function Cross-References

The following table defines the exact `createHandlers()` function used for each safe output type defined in §7:

| Safe Output Type | Handler Function in `actions/setup/js/safe_outputs_handlers.cjs` |
|---|---|
| `create_issue` | `createIssueHandler` |
| `add_comment` | `addCommentHandler` |
| `create_pull_request` | `createPullRequestHandler` |
| `noop` | `defaultHandler("noop")` |
| `comment_memory` | `defaultHandler("comment_memory")` |
| `update_issue` | `updateIssueHandler` |
| `close_issue` | `defaultHandler("close_issue")` |
| `link_sub_issue` | `defaultHandler("link_sub_issue")` |
| `create_discussion` | `defaultHandler("create_discussion")` |
| `update_discussion` | `defaultHandler("update_discussion")` |
| `close_discussion` | `defaultHandler("close_discussion")` |
| `update_pull_request` | `updatePullRequestHandler` |
| `close_pull_request` | `defaultHandler("close_pull_request")` |
| `merge_pull_request` | `defaultHandler("merge_pull_request")` |
| `mark_pull_request_as_ready_for_review` | `defaultHandler("mark_pull_request_as_ready_for_review")` |
| `push_to_pull_request_branch` | `pushToPullRequestBranchHandler` |
| `push_repo_memory` | `pushRepoMemoryHandler` |
| `create_pull_request_review_comment` | `createPullRequestReviewCommentHandler` |
| `submit_pull_request_review` | `submitPullRequestReviewHandler` |
| `dismiss_pull_request_review` | `dismissPullRequestReviewHandler` |
| `resolve_pull_request_review_thread` | `defaultHandler("resolve_pull_request_review_thread")` |
| `reply_to_pull_request_review_comment` | `defaultHandler("reply_to_pull_request_review_comment")` |
Comment thread
Copilot marked this conversation as resolved.
| `add_labels` | `defaultHandler("add_labels")` |
| `remove_labels` | `defaultHandler("remove_labels")` |
| `add_reviewer` | `defaultHandler("add_reviewer")` |
| `assign_milestone` | `defaultHandler("assign_milestone")` |
| `assign_to_agent` | `defaultHandler("assign_to_agent")` |
| `assign_to_user` | `defaultHandler("assign_to_user")` |
| `unassign_from_user` | `defaultHandler("unassign_from_user")` |
| `hide_comment` | `defaultHandler("hide_comment")` |
| `create_project` | `createProjectHandler` |
| `update_project` | `defaultHandler("update_project")` |
| `create_project_status_update` | `defaultHandler("create_project_status_update")` |
| `update_release` | `defaultHandler("update_release")` |
| `upload_asset` | `uploadAssetHandler` |
| `upload_artifact` | `uploadArtifactHandler` |
| `dispatch_workflow` | `defaultHandler("dispatch_workflow")` |
| `create_code_scanning_alert` | `defaultHandler("create_code_scanning_alert")` |
| `autofix_code_scanning_alert` | `defaultHandler("autofix_code_scanning_alert")` |
| `create_check_run` | `defaultHandler("create_check_run")` |
| `create_agent_session` | `defaultHandler("create_agent_session")` |
| `missing_tool` | `defaultHandler("missing_tool")` |
Comment on lines +2032 to +2040
| `missing_data` | `defaultHandler("missing_data")` |
| `report_incomplete` | `defaultHandler("report_incomplete")` |

### 7.1 Core Issue Operations

#### Type: create_issue
Expand Down Expand Up @@ -5326,6 +5377,11 @@ This section maps normative specification requirements (§3–§11) to implement
| §7 Safe Output Type Definitions | Handler implementations for each type | `actions/setup/js/safe_outputs_handlers.cjs`, `actions/setup/js/safe_outputs_tools.json` |
| §7.1 Core Issue Operations | `create_issue`, `add_comment`, `hide_comment`, `close_issue` | `actions/setup/js/add_comment.cjs`, `actions/setup/js/safe_outputs_handlers.cjs` |
| §8 Protocol Exchange Patterns | stdio container transport, tool invocation, MCP server constraint enforcement | `actions/setup/js/safe_outputs_mcp_server.cjs`, `actions/setup/js/safe_outputs_mcp_server_http.cjs` |
| §8.3 MCE1 Early Validation | Invocation-time validation wiring through MCP server startup | `actions/setup/js/safe_outputs_mcp_server.cjs` (`startSafeOutputsServer` → `createHandlers()`), `actions/setup/js/safe_outputs_handlers.cjs` (`addCommentHandler`, `createIssueHandler`, `updatePullRequestHandler`) |
| §8.3 MCE2 Tool Description Disclosure | Tool descriptions/schemas exposed during MCP tool registration | `actions/setup/js/safe_outputs_mcp_server.cjs` (`registerPredefinedTools` call), `actions/setup/js/safe_outputs_tools_loader.cjs` (`registerPredefinedTools`) |
| §8.3 MCE3 Actionable Error Responses | Structured MCP validation errors with remediation guidance | `actions/setup/js/safe_outputs_mcp_server.cjs` (`start()` call serving handler responses), `actions/setup/js/safe_outputs_handlers.cjs` (`buildIntentErrorResponse`, `addCommentHandler`) |
| §8.3 MCE4 Dual Enforcement | Invocation-time max checks before NDJSON append plus processor-time enforcement | `actions/setup/js/safe_outputs_mcp_server.cjs` (`createHandlers()` call path), `actions/setup/js/safe_outputs_handlers.cjs` (`enforcePerTypeMax`, `appendSafeOutputCounted`) |
| §8.3 MCE5 Constraint Configuration Consistency | Shared safe-outputs config passed into handler creation for consistent limits | `actions/setup/js/safe_outputs_mcp_server.cjs` (`bootstrapSafeOutputsServer`, `createHandlers(server, appendSafeOutput, safeOutputsConfig)`), `actions/setup/js/safe_outputs_handlers.cjs` (`getSafeOutputsToolConfig`) |
| §9 Content Integrity Mechanisms | Content sanitization pipeline | `actions/setup/js/safe_output_validator.cjs`, `actions/setup/js/safe_output_type_validator.cjs` |
| §10 Execution Guarantees | Idempotency, staged mode enforcement | `actions/setup/js/safe_outputs_handlers.cjs`, `actions/setup/js/safe_output_action_handler.cjs` |
| §11 Cache Memory Integrity | Cache key format, integrity branches, git-backed branching | `pkg/workflow/compiler_safe_outputs.go`, `actions/setup/js/safe_outputs_bootstrap.cjs` |
Expand All @@ -5337,8 +5393,7 @@ Sync procedure:

Sync follow-up tasks:

- **[Open]** Add cross-references from §7 handler definitions to exact function names in `actions/setup/js/safe_outputs_handlers.cjs` for all 15+ safe output types.
- **[Open]** Map §8.3 MCP Server Constraint Enforcement requirements (MCE1–MCE5) to specific validation calls in `actions/setup/js/safe_outputs_mcp_server.cjs`.
- Keep §7.0.2 and §8.3 mapping rows current when handler names or MCP wiring changes in `actions/setup/js/safe_outputs_handlers.cjs`, `actions/setup/js/safe_outputs_mcp_server.cjs`, or `actions/setup/js/safe_outputs_tools_loader.cjs`.

---

Expand Down
37 changes: 35 additions & 2 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,20 +277,48 @@ func addWorkflows(ctx context.Context, workflows []*ResolvedWorkflow, opts AddOp
return addWorkflowsWithTracking(ctx, workflows, tracker, opts)
}

func prepareGitAttributesTracking(tracker *FileTracker) (path string, existed bool) {
gitRoot, err := gitutil.FindGitRoot()
if err != nil {
addLog.Printf("Skipping .gitattributes tracking setup: failed to find git root: %v", err)
return "", false
}

path = filepath.Join(gitRoot, ".gitattributes")
existed = fileutil.FileExists(path)
if tracker != nil && existed {
tracker.TrackModified(path)
}

return path, existed
}

func trackGitAttributesIfCreated(tracker *FileTracker, path string, existed bool, updated bool) {
if tracker == nil || path == "" || existed || !updated {
return
}
tracker.TrackCreated(path)
}

// addWorkflows handles workflow addition using pre-fetched content
func addWorkflowsWithTracking(ctx context.Context, workflows []*ResolvedWorkflow, tracker *FileTracker, opts AddOptions) error {
addLog.Printf("Adding %d workflow(s) with tracking: force=%v, disableSecurityScanner=%v", len(workflows), opts.Force, opts.DisableSecurityScanner)
// Ensure .gitattributes is configured unless flag is set
if !opts.NoGitattributes {
gitAttributesPath, gitAttributesExisted := prepareGitAttributesTracking(tracker)

addLog.Print("Configuring .gitattributes")
if updated, err := ensureGitAttributes(); err != nil {
addLog.Printf("Failed to configure .gitattributes: %v", err)
if opts.Verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to update .gitattributes: %v", err)))
}
// Don't fail the entire operation if gitattributes update fails
} else if updated && opts.Verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Configured .gitattributes"))
} else if updated {
trackGitAttributesIfCreated(tracker, gitAttributesPath, gitAttributesExisted, updated)
if opts.Verbose {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Configured .gitattributes"))
}
}
}

Expand All @@ -305,6 +333,11 @@ func addWorkflowsWithTracking(ctx context.Context, workflows []*ResolvedWorkflow
}

if err := addWorkflowWithTracking(ctx, resolved, tracker, opts); err != nil {
if tracker != nil {
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Double-rollback risk: addWorkflowsWithTracking now calls tracker.RollbackAllFiles internally, but add_workflow_pr.go (line 93) also calls tracker.RollbackAllFiles on the same tracker when the error returns. Because FileTracker does not clear its lists after rollback, RollbackModifiedFiles re-writes the original content a second time — harmless today but semantically broken and fragile if tracker state is checked post-rollback.

💡 Suggested fix

Clear the tracker lists after a successful rollback so a second call is a no-op:

// After rollback logic in RollbackAllFiles:
ft.CreatedFiles = nil
ft.ModifiedFiles = nil
ft.OriginalContent = nil

Alternatively, guard the outer call in add_workflow_pr.go with a comment noting the inner rollback has already been attempted.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double rollback when caller already runs RollbackAllFiles on the same tracker: add_workflow_pr.go calls tracker.RollbackAllFiles at line 93 after addWorkflowsWithTracking returns an error — which now also calls RollbackAllFiles internally. The second invocation re-runs on an already-rolled-back tracker.

💡 Impact and suggested fix

Today this is safe by coincidence:

  • RollbackCreatedFiles ignores os.IsNotExist, so re-deleting an already-deleted file is a no-op.
  • RollbackModifiedFiles re-writes original content that was already restored, so the result is the same.

But the invariant is broken: the tracker is never cleared after a rollback, and its state looks identical to a pre-rollback state. Any future change to RollbackAllFiles or RollbackModifiedFiles that adds state-dependent logic (e.g., "only restore files not already restored", or clearing the lists post-rollback) will silently change behaviour for the outer caller in add_workflow_pr.go.

Option A (preferred): Move rollback responsibility entirely into addWorkflowsWithTracking and remove the redundant outer call in add_workflow_pr.go line 93.

Option B: After a successful rollback inside addWorkflowsWithTracking, clear the tracker lists so a second call becomes a true no-op:

The PR as written silently relies on two callers sharing a tracker to both call rollback without coordination — that's a maintenance hazard.

return fmt.Errorf("failed to add workflow '%s' (rollback also failed): %w", resolved.Spec.String(), errors.Join(err, rollbackErr))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double-rollback hazard introduced by this change.

After this PR, addWorkflowsWithTracking rolls back internally on failure (lines 308-312). But every existing caller that already calls tracker.RollbackAllFiles() on error — e.g., add_workflow_pr.go line 93 — will now trigger a second rollback on the same tracker after addWorkflowsWithTracking returns the error.

The second rollback silently no-ops (files already deleted), but it is misleading. Rollback ownership should be consistent: either the callers own rollback (revert the internal rollback added here), or addWorkflowsWithTracking owns it (remove the external RollbackAllFiles calls in callers).

@copilot please address this.

}
}
return fmt.Errorf("failed to add workflow '%s': %w", resolved.Spec.String(), err)
Comment on lines 335 to 341

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Rollback success is not asserted — the test only checks that ok.md does not exist after the error, but it doesn't cover the case where rollback itself fails (partial rollback). The error message for rollback failure uses errors.Join(err, rollbackErr) with %w, which wraps only the first argument under Go's errors.Join semantics; the caller can only errors.Is the original write error, not the rollback error.

💡 Suggested additions
  1. Add a test case that makes rollback fail (e.g. by making the already-written file read-only) and asserts the combined error message contains both the write error and rollback error strings.
  2. Consider returning a dedicated type that carries both errors distinctly, or at minimum confirm the wrapping is intentional so errors.Is(err, writeErr) works as expected.

@copilot please address this.

}
}
Expand Down
45 changes: 45 additions & 0 deletions pkg/cli/add_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,51 @@ func TestAddWorkflowWithTracking_ActionWorkflow_Force(t *testing.T) {
assert.Equal(t, newContent, written)
}

func TestAddWorkflowsWithTracking_RollsBackWrittenFilesOnWriteFailure(t *testing.T) {
tempDir := testutil.TempDir(t, "test-add-workflows-rollback-*")
workflowsDir := setupMinimalGitRepo(t, tempDir)

validContent := []byte("---\nengine: claude\n---\n\n# workflow\n")
// workflow name "nested/blocked" intentionally causes write failure because
// addWorkflowWithTracking does not create nested workflow directories.
workflows := []*ResolvedWorkflow{
{
Spec: &WorkflowSpec{
WorkflowPath: "workflows/ok.md",
WorkflowName: "ok",
},
Content: validContent,
SourceInfo: &FetchedWorkflow{
IsLocal: true,
},
},
{
Spec: &WorkflowSpec{
WorkflowPath: "workflows/blocked.md",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test relies on an implicit, undocumented failure mechanism that will silently stop exercising rollback if the implementation changes: WorkflowName: "nested/blocked" triggers a write failure only because no nested/ subdirectory exists — nothing in the test makes this intention visible.

💡 Why this matters and how to harden it

The test relies on os.WriteFile failing because nested/ does not exist and is not created by addWorkflowWithTracking. This is a valid failure path today, but:

  1. add_command.go already calls os.MkdirAll for skill files (line 736) and agent files (line 809). If a future maintainer adds the same os.MkdirAll for standard workflow writes (a plausible extension for nested workflow organization), the failure condition silently disappears and the rollback path is no longer tested. require.Error would catch this — but only because the test as a whole fails, not because the intent is clear.

  2. The test only asserts ok.md was rolled back. It does not assert that blocked.md (the failing write) left no partial state. While os.WriteFile is atomic enough that partial writes are rare, asserting the negative makes the test complete.

Add a comment and a second assertion:

// WorkflowName "nested/blocked" intentionally causes a write failure because the
// "nested/" subdirectory does not exist and addWorkflowWithTracking does not create
// subdirectories for workflow names. This exercises the rollback path.

_, statErr := os.Stat(filepath.Join(workflowsDir, "ok.md"))
assert.True(t, os.IsNotExist(statErr), "successful writes must be rolled back on later write failure")

// blocked.md should also not exist (parent dir never created)
_, blockedErr := os.Stat(filepath.Join(workflowsDir, "nested", "blocked.md"))
assert.True(t, os.IsNotExist(blockedErr), "failing workflow must not leave partial state")

WorkflowName: "nested/blocked",
},
Content: validContent,
SourceInfo: &FetchedWorkflow{
IsLocal: true,
},
},
}

err := addWorkflowsWithTracking(context.Background(), workflows, NewFileTracker(), AddOptions{
NoGitattributes: true,
DisableSecurityScanner: true,
Quiet: true,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to write destination file")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test relies on WorkflowName: "nested/blocked" to trigger a write failure, but the failure mechanism isn't explicit — it's relying on an implementation detail (presumably the nested sub-directory not existing causes the write to fail). This makes the test fragile and unclear: if the implementation starts auto-creating parent directories, the test silently stops exercising rollback.

💡 Suggested improvement

Make the failure condition explicit in the test name and comment, or introduce a test-friendly injection point (e.g., a mock writer) so the cause of failure is decoupled from directory creation behaviour:

// WorkflowName "nested/blocked" causes a write failure because the parent
// directory "nested/" does not exist under workflowsDir and is not created
// by the implementation. If that behaviour changes, this test must be updated.

@copilot please address this.


_, statErr := os.Stat(filepath.Join(workflowsDir, "ok.md"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test relies on an undocumented failure condition that may be fragile.

The test expects WorkflowName: "nested/blocked" to fail with "failed to write destination file". Looking at the code, this fails because nested/blocked implies a subdirectory (.github/workflows/nested/) that does not exist in the test setup, so os.WriteFile fails.

This is an implicit coupling: the test passes only because os.MkdirAll is not called for the workflow subdirectory before writing. If future refactoring adds auto-mkdir for nested workflow names (which is a natural improvement), this test silently stops covering rollback.

Recommend: make the failure condition explicit — e.g., create a read-only file at the target path or use a stub writer — rather than relying on filesystem path creation failure.

@copilot please address this.

assert.True(t, os.IsNotExist(statErr), "successful writes from this operation should be rolled back on later write failure")

_, blockedStatErr := os.Stat(filepath.Join(workflowsDir, "nested", "blocked.md"))
assert.True(t, os.IsNotExist(blockedStatErr), "failing workflow should not leave partial output files")
}

func TestAddSkillFileWithTracking_PreservesPathFromSkillsRoot(t *testing.T) {
gitRoot := testutil.TempDir(t, "test-add-skill-path-*")
resolved := &ResolvedWorkflow{
Expand Down
4 changes: 0 additions & 4 deletions pkg/cli/add_workflow_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,6 @@ func addWorkflowsWithPR(ctx context.Context, workflows []*ResolvedWorkflow, opts
prOpts := opts
if err := addWorkflowsWithTracking(ctx, workflows, tracker, prOpts); err != nil {
addWorkflowPRLog.Printf("Failed to add workflows: %v", err)
// Rollback on error
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
}
return 0, "", fmt.Errorf("failed to add workflows: %w", err)
}

Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/tools_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,16 @@ func TestValidateGitHubGuardPolicy(t *testing.T) {
shouldError: true,
errorMsg: "'github.min-integrity' to be set",
},
{
name: "allowed-repos non-all without min-integrity fails",
toolsMap: map[string]any{
"github": map[string]any{
"allowed-repos": "public",
},
},
shouldError: true,
errorMsg: "'github.min-integrity' is required",
},
{
name: "blocked-users as GitHub Actions expression is valid",
toolsMap: map[string]any{
Expand Down
3 changes: 2 additions & 1 deletion scratchpad/guard-policies-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ The YAML key `repos` under `tools.github` is **deprecated** as of guard-policy s

**Migration path**: Use `gh aw fix` to automatically migrate `repos:` to `allowed-repos:` in workflow frontmatter.

**Removal target**: The `repos` alias SHOULD be removed in a future major version of the spec (tentatively v2.0.0). When the alias is removed, implementations MUST reject `repos` as an unknown field with an error message that suggests `allowed-repos`.
**Removal target**: The `repos` alias SHOULD be removed in a future major version of the spec; tracking is managed in issue [#44357](https://github.com/github/gh-aw/issues/44357). When the alias is removed, implementations MUST reject `repos` as an unknown field with an error message that suggests `allowed-repos`.

---

Expand Down Expand Up @@ -553,6 +553,7 @@ This section maps normative sections of this specification to the implementation
| GP-01, GP-03 pattern validation | Repository pattern format validation (exact, wildcard, prefix) | `pkg/workflow/tools_validation_github.go` (`validateReposScope`, `validateRepoPattern`, `isValidOwnerOrRepo`) |
| GP-02 `min-integrity` validation | Enum value check for `none`/`unapproved`/`approved`/`merged` | `pkg/workflow/tools_validation_github.go` (`validateGitHubGuardPolicy`) |
| GP-04 empty array rejection | Empty `allowed-repos` array detection and error | `pkg/workflow/tools_validation_github.go` (`validateGitHubGuardPolicy`) |
| GP-11 cross-field consistency | `allowed-repos` non-`"all"` without `min-integrity` MUST fail validation | `pkg/workflow/tools_validation_github.go` (`validateGitHubGuardPolicy`), `pkg/workflow/tools_validation_test.go` (`allowed-repos non-all without min-integrity fails`) |
| GP-10 lockdown precedence | Lockdown + guard-policy conflict detection and warning | `pkg/workflow/tools_validation_github.go` (`validateGitHubGuardPolicy`, `emitGitHubLockdownGuardPolicyWarning`) |

### Safe-Outputs Guard Policy Derivation
Expand Down
Loading