Repository: cli/cli

Issue:
Panic using `gh workflow run` in GitHub Actions
### Describe the bug

I was trying to use `gh workflow run` at the end of a step in a GitHub action to trigger another workflow run. After reading some documentation though it seems I'm doing things wrong and should be using [reusable workflows](https://docs.github.com/en/actions/sharing-automations/reusing-workflows) instead, so I'll figure that out elsewhere...

_Anyway,_ when trying to do this in a GitHub Action, the `gh workflow run` call always ended in a panic. Here's the step in the job:

```yaml
      - name: Trigger Release Workflow
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh workflow run release.yml -f tag_name="${TAG}"
```

(the `$TAG` is correctly set in a previous job step) 

Note the use of `secrets.GITHUB_TOKEN`, which might be part of the problem due to how that token is limited in GHA to prevent recursive actions, etc... but maybe not...

Here's the panic:

```
2025-02-28T14:35:15.4937350Z ##[group]Run printf '%s\n' "${TAG}"
2025-02-28T14:35:15.4937658Z [36;1mprintf '%s\n' "${TAG}"[0m
2025-02-28T14:35:15.4937964Z [36;1mgh workflow run release.yml -f tag_name="${TAG}"[0m
2025-02-28T14:35:15.4983044Z shell: /usr/bin/bash -e {0}
2025-02-28T14:35:15.4983277Z env:
2025-02-28T14:35:15.4983452Z   TAG: v0.1.0-nightly
2025-02-28T14:35:15.4983829Z   GITHUB_TOKEN: ***
2025-02-28T14:35:15.4984034Z ##[endgroup]
2025-02-28T14:35:15.5049366Z v0.1.0-nightly
2025-02-28T14:35:15.9590013Z panic: runtime error: invalid memory address or nil pointer dereference
2025-02-28T14:35:15.9591635Z [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x13eed8a]
2025-02-28T14:35:15.9592318Z 
2025-02-28T14:35:15.9596306Z goroutine 1 [running]:
2025-02-28T14:35:15.9598926Z github.com/cli/cli/v2/pkg/cmd/workflow/shared.FindWorkflow(0xc000abd8f0, {0x22c13c0, 0xc0002e48d0}, {0x7ffecc3d2c67, 0xb}, {0xc000abd8e0, 0x1, 0x1})
2025-02-28T14:35:15.9600986Z 	/home/runner/work/cli/cli/pkg/cmd/workflow/shared/shared.go:139 +0x1aa
2025-02-28T14:35:15.9604432Z github.com/cli/cli/v2/pkg/cmd/workflow/shared.ResolveWorkflow({0x7fd132b6afc8, 0xc000724b40}, 0xc0003b4790, 0xc00061f9a0?, {0x22c13c0?, 0xc0002e48d0?}, 0x0?, {0x7ffecc3d2c67, 0xb}, {0xc000abd8e0, ...})
2025-02-28T14:35:15.9606415Z 	/home/runner/work/cli/cli/pkg/cmd/workflow/shared/shared.go:207 +0x2b5
2025-02-28T14:35:15.9607292Z github.com/cli/cli/v2/pkg/cmd/workflow/run.runRun(0xc00084b180)
2025-02-28T14:35:15.9608084Z 	/home/runner/work/cli/cli/pkg/cmd/workflow/run/run.go:273 +0x251
2025-02-28T14:35:15.9609089Z github.com/cli/cli/v2/pkg/cmd/workflow/run.NewCmdRun.func2(0xc000856008?, {0xc0002b6f30?, 0x4?, 0x178a487?})
2025-02-28T14:35:15.9610121Z 	/home/runner/work/cli/cli/pkg/cmd/workflow/run/run.go:128 +0x311
2025-02-28T14:35:15.9610972Z github.com/spf13/cobra.(*Command).execute(0xc000856008, {0xc0002b6f00, 0x3, 0x3})
2025-02-28T14:35:15.9611919Z 	/home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.1/command.go:985 +0xaaa
2025-02-28T14:35:15.9612678Z github.com/spf13/cobra.(*Command).ExecuteC(0xc0006d6008)
2025-02-28T14:35:15.9613440Z 	/home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.1/command.go:1117 +0x3ff
2025-02-28T14:35:15.9614179Z github.com/spf13/cobra.(*Command).ExecuteContextC(...)
2025-02-28T14:35:15.9615150Z 	/home/runner/go/pkg/mod/github.com/spf13/cobra@v1.8.1/command.go:1050
2025-02-28T14:35:15.9615811Z github.com/cli/cli/v2/internal/ghcmd.Main()
2025-02-28T14:35:15.9616459Z 	/home/runner/work/cli/cli/internal/ghcmd/cmd.go:119 +0x53b
2025-02-28T14:35:15.9617003Z main.main()
2025-02-28T14:35:15.9619832Z 	/home/runner/work/cli/cli/cmd/gh/main.go:10 +0x13
2025-02-28T14:35:15.9629795Z ##[error]Process completed with exit code 2.

```

I believe the panic comes from the [cli/cli/pkg/cmd/workflow/shared/shared.go:139](https://github.com/cli/cli/blob/f8d9aac7e50aa3370d6824e7f2b71049af5176a1/pkg/cmd/workflow/shared/shared.go#L139) in the stack there. 

https://github.com/cli/cli/blob/f8d9aac7e50aa3370d6824e7f2b71049af5176a1/pkg/cmd/workflow/shared/shared.go#L131-L140

On line 135 we check for `errors.As() && httpErr.StatusCode == 404` in order to return an `err`, so I'm speculating that the `getWorkflowByID()` call on 132 is returning a `nil` workflow with some other error code that's not a http error or not a `404`, and as a result we get the `nil pointer dereference` on 139. 

I was going to add a PR to return a generic error message between line 137 and 138, but the contributing guide asks (suggests?) to not open PRs unless there's a `help-wanted` on a bug or something, plus in the short time I looked I wasn't sure how to write a test for this so I just opt to give all this info here. 

Again just speculation but maybe something to do with the token I'm using, because using `gh workflow run` on my machine while authenticated works fine:

```
 ➜ gh workflow run release.yml -f tag_name="v0.1.0-nightly"
✓ Created workflow_dispatch event for release.yml at main

To see runs for this workflow, try: gh run list --workflow=release.yml
```

So maybe the `getWorkflowByID()` call is returning a `403` or something due to the token being used? 

### Affected version

This is happening inside GitHub Actions, so I'm not totaly sure what `gh version`, but here's the runner version:

```
2025-02-28T14:34:58.2988493Z Current runner version: '2.322.0'
2025-02-28T14:34:58.3029586Z ##[group]Operating System
2025-02-28T14:34:58.3031098Z Ubuntu
2025-02-28T14:34:58.3032312Z 24.04.2
2025-02-28T14:34:58.3033375Z LTS
2025-02-28T14:34:58.3034492Z ##[endgroup]
2025-02-28T14:34:58.3035920Z ##[group]Runner Image
2025-02-28T14:34:58.3037214Z Image: ubuntu-24.04
2025-02-28T14:34:58.3038421Z Version: 20250223.1.0
```

### Steps to reproduce the behavior

1. Add a workflow (a) that does something and can be dispatched from another workflow (b)
2. In workflow (b) add the step above that uses `gh workflow run`
3. Run workflow (b)
4. Panic? 

### Expected vs actual behavior

Return an error instead of panic, to help educate me on what I'm doing wrong.


I'm happy to PR a fix here as I suspect even if my speculations on tokens are wrong, we probably still want to return `nil, err` between 137 and 138. Tips on how/where to test for that would be cool too, if we want to have a regression test there. 

Discussion:
@williammartin: Hey @catsby,

Thanks for the great bug report.

I'm not sure about the token permissions issue (we can look into that if you like) but you're totally right in your debugging of the panic. It was introduced in this PR: https://github.com/cli/cli/pull/10328

Gonna hand this off to @jtmcg since it's the end of my day.@catsby: Thanks @williammartin! For the token permissions thing, it would be neat to know if my suspicion is correct but I'm not going to request someone go digging just to satisfy my curiosity :P 

I'll still open to sending a PR but again the contributing guide suggests I hold off for now, and aside from any tests it's likely just a 1-line change anyway so I'm indifferent if a maintainer just goes ahead and fixes it.

Have a great weekend 👋 @iamazeem: Hey @catsby,

Tried to reproduce this.
Working fine for me.
Got 404 and 403 but no panic.

Could you please see what I'm missing?

Here are the workflows that I used:

`.github/workflows/ci-wd-test-runner.yml`

```yml
name: wd_test_runner

on: workflow_dispatch

permissions:
  actions: write

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
    - name: Run
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        gh --version
        gh workflow run ci-wd-test.yml --repo iamazeem/test
```

`.github/workflows/ci-wd-test.yml`

```yml
name: wd_test

on: workflow_dispatch

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
    - name: Test
      run: echo
```

Workflow runs:

- https://github.com/iamazeem/test/actions/runs/13593475109/job/38004926608 (runner)
- https://github.com/iamazeem/test/actions/runs/13593477522/job/38004932939

`gh version`

```shell
$ gh version 2.67.0 (2025-02-11)
https://github.com/cli/cli/releases/tag/v2.67.0
```

---

I agree that an error should be returned from the non-404 branch.
Just want to understand the root cause. 🤔 @catsby: Hey @iamazeem - thanks for putting a example together! I'm not able to try it out at this time but hopefully later today I can try it. It looks just like my description, the only difference notice is that in my example I don't use the `--repo` flag so maybe that's a clue. 

> Got 404 and 403 but no panic

That's curious... I wonder how this `getWorkflowByID()` method is returning a `403` error, but you are not getting a nil pointer dereference 🤔 unless I'm just misunderstanding some workflow or codepath somewhere.

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/workflow/shared/shared.go#L155C6-L164@iamazeem: @catsby: Sure, thank you! 👍 

> the only difference notice is that in my example I don't use the `--repo` flag so maybe that's a clue

I'm not using a checkout step that's why `--repo` flag is there.
Doesn't seem related though.
I'll verify it later.

---

> I wonder how this `getWorkflowByID()` method is returning a 403 error

403 here is not from the same path.

Earlier, you mentioned that:

> maybe the `getWorkflowByID()` call is **returning a 403** or something due to the token being used?

So, I deliberately missed `permissions` to generate a 403 error for the default `GITHUB_TOKEN`.
It did generate the 403 error but no panic.

---

**Update**

- Tried using an invalid (non-existent) workflow filename with `gh workflow run` command from the runner workflow.
- Updated both workflows for the `tag_name` input as described in your example.

Both worked fine as expected i.e. failure and success respectively.@catsby: @iamazeem I used your examples to make my own reproduction and found similar results, but with a twist..

> 403 here is not from the same path.

This comment confused me because I couldn't see how you were hitting [this method in shared.go](https://github.com/cli/cli/blob/f8d9aac7e50aa3370d6824e7f2b71049af5176a1/pkg/cmd/workflow/shared/shared.go#L131-L140) where the panic was coming from, you were getting a 403 and no error... 

Sure enough though I got a `403` when I reproduced this **on a public test repo** that I made. Therein lies the issue I suppose, in something I overlooked in my initial report: **I'm encountering this panic on an private/internal repo, not a public one.** As soon as I made my test repo **internal** I got the panic again. My apologies to all for not including that detail, it didn't even occur to me that it would make a difference. 

My testing runs are at https://github.com/catsby/gh_panic_test/actions , you can ignore runs 1-2 as I didn't have things configured right. 

Run 3 shows the 403, with some other error message I didn't bother to investigate because the `403` was what I was not expecting but also looking for.

Run 4 was done **after** I made the repo private/internal. There you can see the panic! 

Run 5 was done after I made the repo public again, and is the same results as Run 3. @jtmcg: Hey y'all, we appreciate the investigation!

While you've been digging into this, I've been writing a fix. Well, I've mostly been writing tests to cover this part of the code, as the fix was easy 😅 

I've got a [PR](https://github.com/cli/cli/pull/10521) up for this.

@catsby, we aren't planning on doing a patch release for this as the issue is fairly isolated, but we will be releasing on Monday or Tuesday next week and I'll make sure this fix gets in. I hope that's not too disruptive for you.@catsby: @jtmcg awesome, thanks for the fast turnaround. No worries about releasing it later, I already changed my workflows to use the reusable approach which makes more sense anyway so I'm not blocked or anything.

Cheers!@andyfeller: ## Short term workaround until the latest GitHub Actions hosted runner image is updated

[As of March 7th](https://github.com/actions/runner-images/blob/1489b3b53cc3faee313617b7264e3c80137aa2e4/images/ubuntu/Ubuntu2404-Readme.md), the GitHub Actions runner images, which are rebuilt weekly, have `v2.67.0` installed.  If you need a temporary workaround, you can [install the latest version](https://github.com/cli/cli?tab=readme-ov-file#installation) within a workflow:

```yaml
      - name: Upgrade to latest gh release
        run: |
          (type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
          	&& sudo mkdir -p -m 755 /etc/apt/keyrings \
                  && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
                  && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
          	&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
          	&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
          	&& sudo apt update \
          	&& sudo apt install gh -y
```

In the example above, this step will setup the GitHub CLI Apt package repo to install the latest for a `ubuntu-latest` workflow.

`actions/runner-images` uses a different process for downloading and installing GitHub CLI that doesn't rely upon Apt repository, which is why you cannot simple `sudo apt upgrade gh`.

> [!NOTE]
> New GitHub Actions runner images should be built early next week.

Solution patch:
diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go
index 61511ed7d..04b5fa199 100644
--- a/pkg/cmd/workflow/shared/shared.go
+++ b/pkg/cmd/workflow/shared/shared.go
@@ -132,9 +132,13 @@ func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector st
 		workflow, err := getWorkflowByID(client, repo, workflowSelector)
 		if err != nil {
 			var httpErr api.HTTPError
-			if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
-				return nil, fmt.Errorf("workflow %s not found on the default branch", workflowSelector)
+			if errors.As(err, &httpErr) {
+				if httpErr.StatusCode == 404 {
+					httpErr.Message = fmt.Sprintf("workflow %s not found on the default branch", workflowSelector)
+				}
+				return nil, httpErr
 			}
+			return nil, err
 		}
 		return []Workflow{*workflow}, nil
 	}



Test patch:
diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go
new file mode 100644
index 000000000..cd53b667c
--- /dev/null
+++ b/pkg/cmd/workflow/shared/shared_test.go
@@ -0,0 +1,422 @@
+package shared
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	ghAPI "github.com/cli/go-gh/v2/pkg/api"
+)
+
+func TestFindWorkflow(t *testing.T) {
+	badRequestURL, err := url.Parse("https://api.github.com/repos/OWNER/REPO/actions/workflows/nonexistentWorkflow.yml")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tests := []struct {
+		name              string
+		workflowSelector  string
+		repo              ghrepo.Interface
+		httpStubs         func(*httpmock.Registry)
+		states            []WorkflowState
+		expectedWorkflow  Workflow
+		expectedHTTPError *api.HTTPError
+		expectedError     error
+	}{
+		{
+			name:             "When the workflow selector is empty, it returns an error",
+			workflowSelector: "",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			expectedError:    errors.New("empty workflow selector"),
+		},
+		{
+			name:             "When the workflow selector is a number, it returns the workflow with that ID",
+			workflowSelector: "1",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/1"),
+					httpmock.StatusJSONResponse(200, Workflow{
+						ID: 1,
+					}),
+				)
+			},
+			expectedWorkflow: Workflow{
+				ID: 1,
+			},
+		},
+		{
+			name:             "When the workflow selector is a file, it returns the workflow with that path",
+			workflowSelector: "workflowFile.yml",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflowFile.yml"),
+					httpmock.StatusJSONResponse(200, Workflow{
+						ID:   1,
+						Name: "Some Workflow",
+						Path: ".github/workflows/workflowFile.yml",
+					}),
+				)
+			},
+			expectedWorkflow: Workflow{
+				ID:   1,
+				Name: "Some Workflow",
+				Path: ".github/workflows/workflowFile.yml",
+			},
+		},
+		{
+			name:             "When the workflow selector is a workflow that doesn't exist, it returns the workflow not found error",
+			workflowSelector: "nonexistentWorkflow.yml",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", strings.TrimPrefix(badRequestURL.Path, "/")),
+					httpmock.StatusJSONResponse(404, Workflow{}),
+				)
+			},
+			expectedHTTPError: &api.HTTPError{
+				HTTPError: &ghAPI.HTTPError{
+					Message:    "workflow nonexistentWorkflow.yml not found on the default branch",
+					StatusCode: 404,
+					RequestURL: badRequestURL,
+				},
+			},
+		},
+		{
+			name:             "When the workflow selector is a file but the server errors, it returns that error",
+			workflowSelector: "nonexistentWorkflow.yml",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", strings.TrimPrefix(badRequestURL.Path, "/")),
+					httpmock.StatusStringResponse(500, "server error"),
+				)
+			},
+			expectedHTTPError: &api.HTTPError{
+				HTTPError: &ghAPI.HTTPError{
+					Errors: []ghAPI.HTTPErrorItem{
+						{
+							Message: "server error",
+						},
+					},
+					StatusCode: 500,
+					RequestURL: badRequestURL,
+				},
+			},
+		},
+		{
+			name:             "When the workflow selector is a name and the state is active, it returns that workflow",
+			workflowSelector: "Workflow Name",
+			repo:             ghrepo.New("OWNER", "REPO"),
+			states:           []WorkflowState{Active},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: []Workflow{
+							{
+								ID:    1,
+								Name:  "Workflow Name",
+								State: Active,
+							},
+						}}),
+				)
+			},
+			expectedWorkflow: Workflow{
+				ID:    1,
+				Name:  "Workflow Name",
+				State: "active",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			if tt.httpStubs != nil {
+				tt.httpStubs(reg)
+			}
+			client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+			workflow, err := FindWorkflow(client, tt.repo, tt.workflowSelector, tt.states)
+			if tt.expectedError != nil {
+				require.Error(t, err)
+				assert.Equal(t, tt.expectedError, err)
+			} else if tt.expectedHTTPError != nil {
+				var httpErr api.HTTPError
+				require.ErrorAs(t, err, &httpErr)
+				assert.Equal(t, tt.expectedHTTPError.Error(), httpErr.Error())
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, tt.expectedWorkflow, workflow[0])
+			}
+		})
+	}
+}
+
+type ErrorTransport struct {
+	Err error
+}
+
+func (t *ErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	return nil, t.Err
+}
+
+func TestFindWorkflow_nonHTTPError(t *testing.T) {
+	t.Run("When the client fails to instantiate, it returns the error", func(t *testing.T) {
+		client := api.NewClientFromHTTP(&http.Client{Transport: &ErrorTransport{Err: errors.New("non-HTTP error")}})
+		repo := ghrepo.New("OWNER", "REPO")
+		workflow, err := FindWorkflow(client, repo, "1", nil)
+
+		require.Error(t, err)
+		assert.ErrorContains(t, err, "non-HTTP error")
+		assert.Nil(t, workflow)
+	})
+}
+
+func Test_getWorkflowsByName_filtering(t *testing.T) {
+	tests := []struct {
+		name              string
+		workflowName      string
+		repo              ghrepo.Interface
+		states            []WorkflowState
+		httpStubs         func(*httpmock.Registry)
+		expectedWorkflows []Workflow
+		expectedErrorMsg  string
+	}{
+		{
+			name:         "When no workflows match, no workflows are returned",
+			workflowName: "Unmatched Workflow Name",
+			repo:         ghrepo.New("OWNER", "REPO"),
+			states:       []WorkflowState{Active},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: []Workflow{
+							{
+								ID:    1,
+								Name:  "Workflow Name",
+								State: Active,
+							},
+							{
+								ID:    2,
+								Name:  "Workflow Name",
+								State: DisabledInactivity,
+							},
+							{
+								ID:    3,
+								Name:  "Workflow Name",
+								State: Active,
+							},
+						},
+					}),
+				)
+			},
+			expectedWorkflows: []Workflow(nil),
+		},
+		{
+			name:         "When there are more than one workflow with the same name, only the ones matching the provided state are returned",
+			workflowName: "Workflow Name",
+			repo:         ghrepo.New("OWNER", "REPO"),
+			states:       []WorkflowState{Active},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: []Workflow{
+							{
+								ID:    1,
+								Name:  "Workflow Name",
+								State: Active,
+							},
+							{
+								ID:    2,
+								Name:  "Workflow Name",
+								State: DisabledInactivity,
+							},
+							{
+								ID:    3,
+								Name:  "Workflow Name",
+								State: Active,
+							},
+						},
+					}),
+				)
+			},
+			expectedWorkflows: []Workflow{
+				{
+					ID:    1,
+					Name:  "Workflow Name",
+					State: Active,
+				},
+				{
+					ID:    3,
+					Name:  "Workflow Name",
+					State: Active,
+				},
+			},
+		},
+		{
+			name: "When GetWorkflows errors",
+			repo: ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusStringResponse(500, ""),
+				)
+			},
+			expectedErrorMsg: "couldn't fetch workflows for OWNER/REPO",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			if tt.httpStubs != nil {
+				tt.httpStubs(reg)
+			}
+			client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+			workflows, err := getWorkflowsByName(client, tt.repo, tt.workflowName, tt.states)
+			if tt.expectedErrorMsg != "" {
+				require.Error(t, err)
+				assert.ErrorContains(t, err, tt.expectedErrorMsg)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, tt.expectedWorkflows, workflows)
+			}
+		})
+	}
+}
+
+func TestGetWorkflows(t *testing.T) {
+	tests := []struct {
+		name              string
+		repo              ghrepo.Interface
+		limit             int
+		httpStubs         func(*httpmock.Registry)
+		expectedWorkflows []Workflow
+		expectedError     error
+	}{
+		{
+			name: "When the repo has no workflows, it returns an empty slice",
+			repo: ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: []Workflow{},
+					}),
+				)
+			},
+			expectedWorkflows: []Workflow{},
+		},
+		{
+			name: "When the api returns workflows, it returns those workflows",
+			repo: ghrepo.New("OWNER", "REPO"),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: []Workflow{
+							{
+								Name: "Workflow 1",
+							},
+							{
+								Name: "Workflow 2",
+							},
+						},
+					}),
+				)
+			},
+			expectedWorkflows: []Workflow{
+				{
+					Name: "Workflow 1",
+				},
+				{
+					Name: "Workflow 2",
+				},
+			},
+		},
+		{
+			name:  "When the api return paginates, it returns the workflows from all the pages",
+			repo:  ghrepo.New("OWNER", "REPO"),
+			limit: 0,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: generateWorkflows(t, 100, 1),
+					}),
+				)
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: generateWorkflows(t, 50, 2),
+					}),
+				)
+			},
+			expectedWorkflows: append(generateWorkflows(t, 100, 1), generateWorkflows(t, 50, 2)...),
+		},
+		{
+			name:  "When the limit is set to fewer workflows than the api returns, it returns the number of workflows specified by the limit",
+			repo:  ghrepo.New("OWNER", "REPO"),
+			limit: 2,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+					httpmock.StatusJSONResponse(200, WorkflowsPayload{
+						Workflows: generateWorkflows(t, 100, 1),
+					}),
+				)
+			},
+			expectedWorkflows: generateWorkflows(t, 2, 1),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			if tt.httpStubs != nil {
+				tt.httpStubs(reg)
+			}
+			client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+			workflows, err := GetWorkflows(client, tt.repo, tt.limit)
+			if tt.expectedError != nil {
+				require.Error(t, err)
+				assert.Equal(t, tt.expectedError, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, tt.expectedWorkflows, workflows)
+			}
+		})
+	}
+}
+
+// generateWorkflows returns an slice of workflows with the given count, labeled
+// with the page number of testing pagination.
+// The page number is used to generate unique Names and IDs for each workflow.
+func generateWorkflows(t *testing.T, workflowCount int, pageNum int) []Workflow {
+	t.Helper()
+	workflows := []Workflow{}
+	for i := 0; i < workflowCount; i++ {
+		workflows = append(workflows, Workflow{
+			Name: fmt.Sprintf("Workflow-%d-%d", pageNum, i),
+			ID:   int64(i) + int64(pageNum-1)*100,
+		})
+	}
+	return workflows
+}