Repository: cli/cli

Issue:
`gh cache delete <cache-key>` misreports count when multiple match
### Describe the bug

When using `gh cache delete` with a cache-key that has multiple matches like so:

<img width="2608" height="460" alt="Image" src="https://github.com/user-attachments/assets/dfb6ff31-49ef-4e29-acc0-a4f9a255c9f2" />

When i run the `gh cache delete test-cache-this-is-always-the-same` it outputs that one cache was successfully deleted. Even though that all three of these was deleted.

<img width="804" height="54" alt="Image" src="https://github.com/user-attachments/assets/9de13b16-0227-449e-b85b-02a6f20b8bf2" />

### Affected version

gh version 2.78.0 (2025-08-21)
https://github.com/cli/cli/releases/tag/v2.78.0

### Steps to reproduce the behavior

1. Create a new repository that includes an action that uses `@actions/cache` with the same key across multiple branches. (I have created a testing repository, which can be used as a starting point https://github.com/luxass/gh-cache-test)
2. Verify with `gh cache list`
3. Run `gh cache delete` which prints that one cache was successfully deleted.

### Expected vs actual behavior

I would expect that it prints the correct number of caches that was deleted.

### Additional Context

The REST Api supports returning the list of deleted caches when using a cache-key.

https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-github-actions-caches-for-a-repository-using-a-cache-key

So there should probably be some conditional output, since the delete by cache-id returns a 204 status code.

### Logs

Paste the activity from your command line. Redact if needed.

<!-- Note: Set `GH_DEBUG=true` for verbose logs or `GH_DEBUG=api` for verbose logs with HTTP traffic details. -->


Hints from issue comments:
@babakks: Thanks for reporting this, @luxass! 🙏

I can confirm the issue. Here's what I tried:

```sh
gh cache list --json ref,key,id | cat
# [{"id":1257026745,"key":"some-key","ref":"refs/heads/main-copy-2"},{"id":1256992430,"key":"some-key","ref":"refs/heads/main"}]
gh cache delete some-key
# ✓ Deleted 1 cache from gh-babakks/foo
gh cache list --json ref,key,id | cat
# []
```

Although two cache entries were deleted, the message says "1". However, when I tried with `--all` I got "2":

```sh
gh cache delete --all                
# ✓ Deleted 2 caches from gh-babakks/foo
```

In whatever way we look at this (i.e. should/shouldn't count distinct keys), the outputs are obviously inconsistent. So, this needs to be fixed.

## Triage notes on potential fix

The reason behind this is that we use two different API endpoints to delete cache entries:

1. [delete by ID](https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id), where an ID is equivalent to a unique combination of key/ref values. This endpoint just responds with `HTTP 204` (no content).
2. [delete by key and an optional ref](https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-github-actions-caches-for-a-repository-using-a-cache-key), which responds with a detailed list of deleted entries (like a list request) accompanied by a top-level `total_count` field:

   ```jsonc
   {
     "total_count": 2,
     "actions_caches": [/*...*/]
   }
   ```
   In this case `gh` **ignores the count** reported in the response body and assumes it has successfully deleted *one* entry, which is not necessarily accurate.

So the fix is to update `gh` so that when it's deleting cache entries by key, the count should be taken from the response body.

> [!NOTE]
> Although this bug affects lots of users, the impact is merely a confusion for the user and nothing unusual/unexpected will happen. So, I marked this as a https://github.com/cli/cli/labels/p3 to avoid unnecessary prioritisation.
@babakks: As this seems to be a straightforward change, I'm going to put down the A/C and label it as https://github.com/cli/cli/labels/help%20wanted.

@luxass, if you're up for it, feel free to submit a fix.

@babakks: ## Acceptance Criteria

### Keep current behaviour

**Given** I have a repo with non-zero number of cache entries 
**When** I run `gh cache delete --all`
**Then** I see a success message with the same number of cache entries (distinct count by key/ref combination)

### Delete by key

**Given** I have a repo with multiple cache entries with the **same key**
**When** I run `gh cache delete <KEY>`
**Then** I see a success message with the number of entries of the same key

### Delete by key and ref

**Given** I have a repo with non-zero number of cache entries
**When** I run `gh cache delete <KEY> --ref <REF>`
**Then** I see a success message with the number of deleted entries

Solution patch:
diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go
index 58281d831..b8367cb5c 100644
--- a/pkg/cmd/cache/delete/delete.go
+++ b/pkg/cmd/cache/delete/delete.go
@@ -164,33 +164,18 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface
 	cs := opts.IO.ColorScheme()
 	repoName := ghrepo.FullName(repo)
 	opts.IO.StartProgressIndicator()
-	base := fmt.Sprintf("repos/%s/actions/caches", repoName)
 
+	totalDeleted := 0
 	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 := ""
+		var count int
+		var err error
 		if id, ok := parseCacheID(cache); ok {
-			path = fmt.Sprintf("%s/%d", base, id)
+			err = deleteCacheByID(client, repo, id)
+			count = 1
 		} else {
-			path = fmt.Sprintf("%s?key=%s", base, url.QueryEscape(cache))
-
-			if opts.Ref != "" {
-				path += fmt.Sprintf("&ref=%s", url.QueryEscape(opts.Ref))
-			}
+			count, err = deleteCacheByKey(client, repo, cache, opts.Ref)
 		}
 
-		err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
 		if err != nil {
 			var httpErr api.HTTPError
 			if errors.As(err, &httpErr) {
@@ -207,17 +192,45 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface
 			opts.IO.StopProgressIndicator()
 			return err
 		}
+
+		totalDeleted += count
 	}
 
 	opts.IO.StopProgressIndicator()
 
 	if opts.IO.IsStdoutTTY() {
-		fmt.Fprintf(opts.IO.Out, "%s Deleted %s from %s\n", cs.SuccessIcon(), text.Pluralize(len(toDelete), "cache"), repoName)
+		fmt.Fprintf(opts.IO.Out, "%s Deleted %s from %s\n", cs.SuccessIcon(), text.Pluralize(totalDeleted, "cache"), repoName)
 	}
 
 	return nil
 }
 
+func deleteCacheByID(client *api.Client, repo ghrepo.Interface, id int) error {
+	// returns HTTP 204 (NO CONTENT) on success
+	path := fmt.Sprintf("repos/%s/actions/caches/%d", ghrepo.FullName(repo), id)
+	return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
+}
+
+// deleteCacheByKey deletes cache entries by given key (and optional ref) and
+// returns the number of deleted entries.
+//
+// Note that a key/ref combination does not necessarily map to a single cache
+// entry. There may be more than one entries with the same key/ref combination,
+// but those entries will have different IDs.
+func deleteCacheByKey(client *api.Client, repo ghrepo.Interface, key, ref string) (int, error) {
+	path := fmt.Sprintf("repos/%s/actions/caches?key=%s", ghrepo.FullName(repo), url.QueryEscape(key))
+	if ref != "" {
+		path += fmt.Sprintf("&ref=%s", url.QueryEscape(ref))
+	}
+	var payload shared.CachePayload
+	err := client.REST(repo.RepoHost(), "DELETE", path, nil, &payload)
+	if err != nil {
+		return 0, err
+	}
+
+	return payload.TotalCount, nil
+}
+
 func parseCacheID(arg string) (int, bool) {
 	id, err := strconv.Atoi(arg)
 	return id, err == nil



Test patch:
diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go
index 4cfa3ce26..a05c57c7c 100644
--- a/pkg/cmd/cache/delete/delete_test.go
+++ b/pkg/cmd/cache/delete/delete_test.go
@@ -235,13 +235,30 @@ func TestDeleteRun(t *testing.T) {
 					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
 						"key": []string{"a weird＿cache+key"},
 					}),
-					// The response is a JSON object but we don't need it here.
-					httpmock.StatusStringResponse(200, "{}"),
+					httpmock.JSONResponse(shared.CachePayload{
+						TotalCount: 1,
+					}),
 				)
 			},
 			tty:        true,
 			wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n",
 		},
+		{
+			name: "deletes multiple caches by key",
+			opts: DeleteOptions{Identifier: "shared-cache-key"},
+			stubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.QueryMatcher("DELETE", "repos/OWNER/REPO/actions/caches", url.Values{
+						"key": []string{"shared-cache-key"},
+					}),
+					httpmock.JSONResponse(shared.CachePayload{
+						TotalCount: 5,
+					}),
+				)
+			},
+			tty:        true,
+			wantStdout: "✓ Deleted 5 caches from OWNER/REPO\n",
+		},
 		{
 			name: "no caches to delete when deleting all",
 			opts: DeleteOptions{DeleteAll: true},
@@ -299,8 +316,9 @@ func TestDeleteRun(t *testing.T) {
 						"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, "{}"),
+					httpmock.JSONResponse(shared.CachePayload{
+						TotalCount: 1,
+					}),
 				)
 			},
 			tty:        true,
@@ -315,13 +333,31 @@ func TestDeleteRun(t *testing.T) {
 						"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, "{}"),
+					httpmock.JSONResponse(shared.CachePayload{
+						TotalCount: 1,
+					}),
 				)
 			},
 			tty:        false,
 			wantStdout: "",
 		},
+		{
+			name: "deletes multiple caches by key and ref",
+			opts: DeleteOptions{Identifier: "cache-key", Ref: "refs/heads/feature"},
+			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/feature"},
+					}),
+					httpmock.JSONResponse(shared.CachePayload{
+						TotalCount: 3,
+					}),
+				)
+			},
+			tty:        true,
+			wantStdout: "✓ Deleted 3 caches from OWNER/REPO\n",
+		},
 		{
 			// As of now, the API returns HTTP 404 for invalid or non-existent refs.
 			name: "cache key exists but ref is invalid/not-found",