Repository: cli/cli

Issue:
Support `--ref` parameter in `gh cache delete`
### Describe the feature or problem you'd like to solve

The `gh cache delete` command currently lacks support for the `--ref` parameter to narrow the caches to delete down.

GitHub's REST API has support for the `--ref` query parameter: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-github-actions-caches-for-a-repository-using-a-cache-key


### Proposed solution

Add a `--ref` flag to the `gh cache delete` command that accepts Git references in the same format as `gh cache list --ref`:

- `gh cache delete <key> --ref refs/heads/main`
- `gh cache delete <key> --ref refs/pull/123/merge`

Benefits:
  - Safer automated cleanup: CI workflows can clean up PR-specific caches without affecting other branches
  - Better cache management: Users can target specific branches for cache deletion
  - Consistency: Aligns the delete command with the list command's filtering capabilities

### Additional context

I will gladly open a PR for this, if this is a welcoming change :)

Hints from issue comments:
@github-actions: Thank you for your issue! We have categorized it as a feature request, and it has been added to our backlog. In doing so, **we are not committing to implementing this feature at this time**, but, we will consider it for future releases based on community feedback and our own product roadmap. 

Unless you see the https://github.com/cli/cli/labels/help%20wanted label, we are not currently looking for external contributions for this feature.

**If you come across this issue and would like to see it implemented, please add a thumbs up!** This will help us prioritize the feature. Please only comment if you have additional information or viewpoints to contribute.
@babakks: Hi @luxass, and thanks for the nice suggestion! 🙏

I can see the value to this addition as it'll allow users to purge a specific piece of a cache entry.

Please be aware that as with any https://github.com/cli/cli/labels/enhancement issues, we need to discuss this internally and finalise an A/C for it. So, I'm going to lay out the context needed for discussion.
 
## Investigations

It's a pretty straightforward change:

- The `ref` query parameter is also supported on GHES v3.14 (if not earlier), so there's no feature detection required for this change.
- The `ref` field is already present in the `cache list --json` output, so no need to touch that command.@Cpoing: Hi! I’d like to work on this as a first-time contributor if that’s okay.@babakks: Thanks for your interest in helping us, @Cpoing! 🙏 

However, the issue is not marked as https://github.com/cli/cli/labels/help%20wanted, which means the team has yet to decide if this should be picked up by our community. Please follow this issue for future updates.@babakks: @luxass @Cpoing I'm going to put down the A/C for this change and mark it as https://github.com/cli/cli/labels/help%20wanted. Since both of you folks have kindly expressed interest in submitting a PR for this, to avoid working in parallel, please comment here if any of you are going to work on this. 🙏

Regardless of who is going to make the PR, I want to appreciate your help and your will to make `gh` better. ❤️

@babakks: ## Acceptance Criteria

### 1. Existing behaviour
**When** I run `gh cache delete <key>` with a valid/existing `<key>`
**Then** the cached data associated with the given `<key>` is deleted

### 2. Delete with `--ref`
**When** I run `gh cache delete <key> --ref <ref>` with a valid/existing `<key>` and `<ref>` values
**Then** the cached data associated with the given `<ref>` and `<key>` is deleted

### 3. Invalid `--ref` value
**When** I run `gh cache delete <key> --ref <ref>` with an invalid `<ref>` value
**Then** I get the error returned by the API call

> [!NOTE]
> This is probably going to be done already. I'm just repeating to hint at that we don't need to perform client-side validation (as we don't in `cache list`). This is to make the experience consistent with the `cache list` command, and also keep `gh` forward-compatible if the API changes in favour of accepting non-full refs (e.g. a branch name or PR number). FWIW I've seen API endpoints that behave like this.

### 4. Invalid option usages

#### 4.1. Use with no arg (i.e. cache key/ID)
**When** I run `gh cache delete --ref <ref>`
**Then** I get an error saying `--ref` cannot be used without cache key/ID

#### 4.2. Use with `--all`
**When** I run `gh cache delete --all --ref <ref>`
**Then** I get an error saying `--ref` cannot be used with `--all`

### 5. Docs
**When** I run `gh cache delete --help`
**Then** I see the docs include the `--ref` option with examples:

```
FLAGS
  -r, --ref string        Narrow down deletion to a specific ref, formatted as refs/heads/<branch name> or refs/pull/<number>/merge

EXAMPLES
  # Delete a cache by key and branch ref
  $ gh cache delete cache-key --ref refs/heads/feature-branch

  # Delete a cache by key and PR ref
  $ gh cache delete cache-key --ref refs/pull/<PR-number>/merge
```

> [!NOTE]
> The examples should be put where it makes sense (i.e. not just at the end).
@luxass: @babakks I'd like to take this on - I've already started working on some of the implementation locally. I should be able to get a PR up within the next few hours.@babakks: Thanks, @luxass! It's great! I'm looking forward to it.

Solution patch:
diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go
index ab76368ad..58281d831 100644
--- a/pkg/cmd/cache/delete/delete.go
+++ b/pkg/cmd/cache/delete/delete.go
@@ -25,6 +25,7 @@ type DeleteOptions struct {
 	DeleteAll         bool
 	SucceedOnNoCaches bool
 	Identifier        string
+	Ref               string
 }
 
 func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
@@ -51,6 +52,12 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 			# Delete a cache by id in a specific repo
 			$ gh cache delete 1234 --repo cli/cli
 
+			# Delete a cache by key and branch ref
+			$ gh cache delete cache-key --ref refs/heads/feature-branch
+
+			# Delete a cache by key and PR ref
+			$ gh cache delete cache-key --ref refs/pull/<PR-number>/merge
+
 			# Delete all caches (exit code 1 on no caches)
 			$ gh cache delete --all
 
@@ -69,14 +76,31 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 				return err
 			}
 
+			if err := cmdutil.MutuallyExclusive(
+				"--ref cannot be used with --all",
+				opts.DeleteAll, opts.Ref != "",
+			); err != nil {
+				return err
+			}
+
 			if !opts.DeleteAll && opts.SucceedOnNoCaches {
 				return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all")
 			}
 
+			if opts.Ref != "" && len(args) == 0 {
+				return cmdutil.FlagErrorf("must provide a cache key")
+			}
+
 			if !opts.DeleteAll && len(args) == 0 {
 				return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all")
 			}
 
+			if len(args) > 0 && opts.Ref != "" {
+				if _, ok := parseCacheID(args[0]); ok {
+					return cmdutil.FlagErrorf("--ref cannot be used with cache ID")
+				}
+			}
+
 			if len(args) == 1 {
 				opts.Identifier = args[0]
 			}
@@ -90,6 +114,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 	}
 
 	cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches")
+	cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "Delete by cache key and ref, formatted as refs/heads/<branch name> or refs/pull/<number>/merge")
 	cmd.Flags().BoolVar(&opts.SucceedOnNoCaches, "succeed-on-no-caches", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`")
 
 	return cmd
@@ -142,11 +167,27 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface
 	base := fmt.Sprintf("repos/%s/actions/caches", repoName)
 
 	for _, cache := range toDelete {
+		// TODO(babakks): We use two different endpoints here which have different
+		// response schemas:
+		//
+		//   1. /repos/OWNER/REPO/actions/caches/ID (for deleting by cache ID)
+		//      - returns HTTP 204 (NO CONTENT) on success
+		//   2. /repos/OWNER/REPO/actions/caches?key=KEY[&ref=REF] (for deleting by cache key, and optionally a ref)
+		//      - returns HTTP 200 on success including information about the deleted caches
+		//
+		// So, if/when we decided to use the data in the response body we need
+		// to be careful with parsing. Probably want to split these API calls
+		// into separate functions.
+
 		path := ""
-		if id, err := strconv.Atoi(cache); err == nil {
+		if id, ok := parseCacheID(cache); ok {
 			path = fmt.Sprintf("%s/%d", base, id)
 		} else {
 			path = fmt.Sprintf("%s?key=%s", base, url.QueryEscape(cache))
+
+			if opts.Ref != "" {
+				path += fmt.Sprintf("&ref=%s", url.QueryEscape(opts.Ref))
+			}
 		}
 
 		err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
@@ -154,7 +195,11 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface
 			var httpErr api.HTTPError
 			if errors.As(err, &httpErr) {
 				if httpErr.StatusCode == http.StatusNotFound {
-					err = fmt.Errorf("%s Could not find a cache matching %s in %s", cs.FailureIcon(), cache, repoName)
+					if opts.Ref == "" {
+						err = fmt.Errorf("%s Could not find a cache matching %s in %s", cs.FailureIcon(), cache, repoName)
+					} else {
+						err = fmt.Errorf("%s Could not find a cache matching %s (with ref %s) in %s", cs.FailureIcon(), cache, opts.Ref, repoName)
+					}
 				} else {
 					err = fmt.Errorf("%s Failed to delete cache: %w", cs.FailureIcon(), err)
 				}
@@ -172,3 +217,8 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface
 
 	return nil
 }
+
+func parseCacheID(arg string) (int, bool) {
+	id, err := strconv.Atoi(arg)
+	return id, err == nil
+}



Test patch:
diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go
index a347b64b2..29dd6fcd9 100644
--- a/pkg/cmd/attestation/trustedroot/trustedroot.go
+++ b/pkg/cmd/attestation/trustedroot/trustedroot.go
@@ -37,8 +37,6 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
 		Args:  cobra.ExactArgs(0),
 		Short: "Output trusted_root.jsonl contents, likely for offline verification",
 		Long: heredoc.Docf(`
-			### NOTE: This feature is currently in public preview, and subject to change.
-
 			Output contents for a trusted_root.jsonl file, likely for offline verification.
 
 			When using %[1]sgh attestation verify%[1]s, if your machine is on the internet,
diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go
index 8660d693b..4cfa3ce26 100644
--- a/pkg/cmd/cache/delete/delete_test.go
+++ b/pkg/cmd/cache/delete/delete_test.go
@@ -58,11 +58,36 @@ func TestNewCmdDelete(t *testing.T) {
 			cli:      "--succeed-on-no-caches 123",
 			wantsErr: "--succeed-on-no-caches must be used in conjunction with --all",
 		},
+		{
+			name:     "key argument and delete all flag",
+			cli:      "cache-key --all",
+			wantsErr: "specify only one of cache id, cache key, or --all",
+		},
 		{
 			name:     "id argument and delete all flag",
 			cli:      "1 --all",
 			wantsErr: "specify only one of cache id, cache key, or --all",
 		},
+		{
+			name:  "key argument with ref",
+			cli:   "cache-key --ref refs/heads/main",
+			wants: DeleteOptions{Identifier: "cache-key", Ref: "refs/heads/main"},
+		},
+		{
+			name:     "ref flag without cache key",
+			cli:      "--ref refs/heads/main",
+			wantsErr: "must provide a cache key",
+		},
+		{
+			name:     "ref flag with cache id",
+			cli:      "123 --ref refs/heads/main",
+			wantsErr: "--ref cannot be used with cache ID",
+		},
+		{
+			name:     "ref flag with all flag",
+			cli:      "--all --ref refs/heads/main",
+			wantsErr: "--ref cannot be used with --all",
+		},
 	}
 
 	for _, tt := range tests {
@@ -89,6 +114,7 @@ func TestNewCmdDelete(t *testing.T) {
 			assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll)
 			assert.Equal(t, tt.wants.SucceedOnNoCaches, gotOpts.SucceedOnNoCaches)
 			assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier)
+			assert.Equal(t, tt.wants.Ref, gotOpts.Ref)
 		})
 	}
 }
@@ -209,7 +235,8 @@ func TestDeleteRun(t *testing.T) {
 					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
 						"key": []string{"a weird＿cache+key"},
 					}),
-					httpmock.StatusStringResponse(204, ""),
+					// The response is a JSON object but we don't need it here.
+					httpmock.StatusStringResponse(200, "{}"),
 				)
 			},
 			tty:        true,
@@ -263,6 +290,54 @@ func TestDeleteRun(t *testing.T) {
 			wantErr:    false,
 			wantStdout: "",
 		},
+		{
+			name: "deletes cache with ref tty",
+			opts: DeleteOptions{Identifier: "cache-key", Ref: "refs/heads/main"},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
+						"key": []string{"cache-key"},
+						"ref": []string{"refs/heads/main"},
+					}),
+					// The response is a JSON object but we don't need it here.
+					httpmock.StatusStringResponse(200, "{}"),
+				)
+			},
+			tty:        true,
+			wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n",
+		},
+		{
+			name: "deletes cache with ref non-tty",
+			opts: DeleteOptions{Identifier: "cache-key", Ref: "refs/heads/main"},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
+						"key": []string{"cache-key"},
+						"ref": []string{"refs/heads/main"},
+					}),
+					// The response is a JSON object but we don't need it here.
+					httpmock.StatusStringResponse(200, "{}"),
+				)
+			},
+			tty:        false,
+			wantStdout: "",
+		},
+		{
+			// As of now, the API returns HTTP 404 for invalid or non-existent refs.
+			name: "cache key exists but ref is invalid/not-found",
+			opts: DeleteOptions{Identifier: "existing-cache-key", Ref: "invalid-ref"},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
+						"key": []string{"existing-cache-key"},
+						"ref": []string{"invalid-ref"},
+					}),
+					httpmock.StatusStringResponse(404, ""),
+				)
+			},
+			wantErr:    true,
+			wantErrMsg: "X Could not find a cache matching existing-cache-key (with ref invalid-ref) in OWNER/REPO",
+		},
 	}
 
 	for _, tt := range tests {