Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow anyone to approve if approvers is left blank #113

Closed
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Build
run: make build
env:
Expand Down
5 changes: 2 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
FROM golang:1.17 AS builder
FROM golang:1.22-alpine AS builder
COPY . /var/app
WORKDIR /var/app
RUN CGO_ENABLED=0 go build -o app .

FROM alpine:3.14
LABEL org.opencontainers.image.source https://github.com/trstringer/manual-approval
FROM alpine:3.19
RUN apk update && apk add ca-certificates
COPY --from=builder /var/app/app /var/app/app
CMD ["/var/app/app"]
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
IMAGE_REPO=ghcr.io/trstringer/manual-approval
IMAGE_REPO=ghcr.io/delphia/manual-approval

.PHONY: build
build:
Expand Down
33 changes: 19 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Manual Workflow Approval

[![ci](https://github.com/trstringer/manual-approval/actions/workflows/ci.yaml/badge.svg)](https://github.com/trstringer/manual-approval/actions/workflows/ci.yaml)
This is a fork of [trstringer/manual-approval](https://github.com/trstringer/manual-approval) with the following changes:

- Leaving the approvers field blank allows anyone with access to the repo to approve.
- Golang version and library updates.

[![ci](https://github.com/Delphia/manual-approval/actions/workflows/ci.yaml/badge.svg)](https://github.com/Delphia/manual-approval/actions/workflows/ci.yaml)

Pause a GitHub Actions workflow and require manual approval from one or more approvers before continuing.

Expand All @@ -11,8 +16,8 @@ This is a very common feature for a deployment or release pipeline, and while [t
The way this action works is the following:

1. Workflow comes to the `manual-approval` action.
1. `manual-approval` will create an issue in the containing repository and assign it to the `approvers`.
1. If and once all approvers respond with an approved keyword, the workflow will continue.
1. `manual-approval` will create an issue in the containing repository and assign it to the `approvers` (if there are any).
1. If and once all required approvers respond with an approved keyword, the workflow will continue.
1. If any of the approvers responds with a denied keyword, then the workflow will exit with a failed status.

* Approval keywords - "approve", "approved", "lgtm", "yes"
Expand All @@ -26,7 +31,7 @@ In all cases, `manual-approval` will close the initial GitHub issue.

```yaml
steps:
- uses: trstringer/manual-approval@v1
- uses: Delphia/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: user1,user2,org-team1
Expand Down Expand Up @@ -71,13 +76,17 @@ jobs:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for approval
uses: trstringer/manual-approval@v1
uses: Delphia/manual-approval@v1
with:
secret: ${{ steps.generate_token.outputs.token }}
approvers: myteam
minimum-approvals: 1
```

## Allow anyone to approve

Leave the approvers field blank to allow anyone to approve.

## Timeout

If you'd like to force a timeout of your workflow pause, you can specify `timeout-minutes` at either the [step](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes) level or the [job](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) level.
Expand All @@ -86,7 +95,7 @@ For instance, if you want your manual approval step to timeout after an hour you

```yaml
steps:
- uses: trstringer/manual-approval@v1
- uses: Delphia/manual-approval@v1
timeout-minutes: 60
...
```
Expand All @@ -112,30 +121,26 @@ For more information on permissions, please look at the [GitHub documentation](h

### Running test code

To test out your code in an action, you need to build the image and push it to a different container registry repository. For instance, if I want to test some code I won't build the image with the main image repository. Prior to this, comment out the label binding the image to a repo:

```dockerfile
# LABEL org.opencontainers.image.source https://github.com/trstringer/manual-approval
```
To test out your code in an action, you need to build the image and push it to a different container registry repository. For instance, if I want to test some code I won't build the image with the main image repository.

Build the image:

```
$ VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/manual-approval-test build
$ VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/Delphia/manual-approval-test build
```

*Note: The image version can be whatever you want, as this image wouldn't be pushed to production. It is only for testing.*

Push the image to your container registry:

```
$ VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/manual-approval-test push
$ VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/Delphia/manual-approval-test push
```

To test out the image you will need to modify `action.yaml` so that it points to your new image that you're testing:

```yaml
image: docker://ghcr.io/trstringer/manual-approval-test:1.7.0-rc.1
image: docker://ghcr.io/Delphia/manual-approval-test:1.7.0-rc.1
```

Then to test out the image, run a workflow specifying your dev branch:
Expand Down
4 changes: 2 additions & 2 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ branding:
inputs:
approvers:
description: Required approvers
required: true
required: false
secret:
description: Secret
required: true
Expand All @@ -21,7 +21,7 @@ inputs:
required: false
exclude-workflow-initiator-as-approver:
description: Whether or not to filter out the user who initiated the workflow as an approver if they are in the approvers list
default: false
default: 'false'
additional-approved-words:
description: Comma separated list of words that can be used to approve beyond the defaults.
default: ''
Expand Down
37 changes: 26 additions & 11 deletions approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"regexp"
"strings"

"github.com/google/go-github/v43/github"
"github.com/google/go-github/v61/github"
)

type approvalEnvironment struct {
Expand All @@ -20,10 +20,11 @@ type approvalEnvironment struct {
issueTitle string
issueBody string
issueApprovers []string
disallowedUsers []string
minimumApprovals int
}

func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string) (*approvalEnvironment, error) {
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, disallowedUsers []string) (*approvalEnvironment, error) {
repoOwnerAndName := strings.Split(repoFullName, "/")
if len(repoOwnerAndName) != 2 {
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
Expand All @@ -37,6 +38,7 @@ func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner strin
repoOwner: repoOwner,
runID: runID,
issueApprovers: approvers,
disallowedUsers: disallowedUsers,
minimumApprovals: minimumApprovals,
issueTitle: issueTitle,
issueBody: issueBody,
Expand All @@ -54,14 +56,19 @@ func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
issueTitle = fmt.Sprintf("%s: %s", issueTitle, a.issueTitle)
}

issueApproversText := "Anyone can approve."
if len(a.issueApprovers) > 0 {
issueApproversText = fmt.Sprintf("%s", a.issueApprovers)
}

issueBody := fmt.Sprintf(`Workflow is pending manual review.
URL: %s

Required approvers: %s

Respond %s to continue workflow or %s to cancel.`,
a.runURL(),
a.issueApprovers,
issueApproversText,
formatAcceptedWords(approvedWords),
formatAcceptedWords(deniedWords),
)
Expand Down Expand Up @@ -93,18 +100,27 @@ Respond %s to continue workflow or %s to cancel.`,
return nil
}

func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
remainingApprovers := make([]string, len(approvers))
copy(remainingApprovers, approvers)
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int, disallowedUsers []string) (approvalStatus, error) {

approvals := []string{}

if minimumApprovals == 0 {
if len(approvers) == 0 {
return "", fmt.Errorf("error: no required approvers or minimum approvals set")
}
minimumApprovals = len(approvers)
}

for _, comment := range comments {
commentUser := comment.User.GetLogin()
approverIdx := approversIndex(remainingApprovers, commentUser)
if approverIdx < 0 {

if approversIndex(disallowedUsers, commentUser) >= 0 {
continue
}
if approversIndex(approvals, commentUser) >= 0 {
continue
}
if len(approvers) > 0 && approversIndex(approvers, commentUser) < 0 {
continue
}

Expand All @@ -114,11 +130,10 @@ func approvalFromComments(comments []*github.IssueComment, approvers []string, m
return approvalStatusPending, err
}
if isApprovalComment {
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
approvals = append(approvals, commentUser)
if len(approvals) >= minimumApprovals {
return approvalStatusApproved, nil
}
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
continue
}

Expand Down
5 changes: 3 additions & 2 deletions approval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package main
import (
"testing"

"github.com/google/go-github/v43/github"
"github.com/google/go-github/v61/github"
)

func TestApprovalFromComments(t *testing.T) {
Expand All @@ -18,6 +18,7 @@ func TestApprovalFromComments(t *testing.T) {
name string
comments []*github.IssueComment
approvers []string
disallowedUsers []string
minimumApprovals int
expectedStatus approvalStatus
}{
Expand Down Expand Up @@ -162,7 +163,7 @@ func TestApprovalFromComments(t *testing.T) {

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actual, err := approvalFromComments(testCase.comments, testCase.approvers, testCase.minimumApprovals)
actual, err := approvalFromComments(testCase.comments, testCase.approvers, testCase.minimumApprovals, testCase.disallowedUsers)
if err != nil {
t.Fatalf("error getting approval from comments: %v", err)
}
Expand Down
33 changes: 24 additions & 9 deletions approvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,37 @@ import (
"strconv"
"strings"

"github.com/google/go-github/v43/github"
"github.com/google/go-github/v61/github"
)

func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error) {
func retrieveApprovers(client *github.Client, repoOwner string) ([]string, []string, error) {
workflowInitiator := os.Getenv(envVarWorkflowInitiator)
shouldExcludeWorkflowInitiatorRaw := os.Getenv(envVarExcludeWorkflowInitiatorAsApprover)
shouldExcludeWorkflowInitiator, parseBoolErr := strconv.ParseBool(shouldExcludeWorkflowInitiatorRaw)
if parseBoolErr != nil {
return nil, fmt.Errorf("error parsing exclude-workflow-initiator-as-approver flag: %w", parseBoolErr)
return nil, nil, fmt.Errorf("error parsing exclude-workflow-initiator-as-approver flag: %w", parseBoolErr)
}

approvers := []string{}
requiredApproversRaw := os.Getenv(envVarApprovers)
requiredApprovers := strings.Split(requiredApproversRaw, ",")
requiredApprovers := []string{}
if requiredApproversRaw != "" {
requiredApprovers = strings.Split(requiredApproversRaw, ",")
}

minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := len(approvers)

var disallowedUsers []string
if shouldExcludeWorkflowInitiator {
disallowedUsers = []string{workflowInitiator}
} else {
disallowedUsers = []string{}
}

if len(requiredApprovers) == 0 {
return []string{}, disallowedUsers, nil
}

for i := range requiredApprovers {
requiredApprovers[i] = strings.TrimSpace(requiredApprovers[i])
Expand All @@ -39,21 +56,19 @@ func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error

approvers = deduplicateUsers(approvers)

minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := len(approvers)
var err error
if minimumApprovalsRaw != "" {
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
if err != nil {
return nil, fmt.Errorf("error parsing minimum number of approvals: %w", err)
return nil, nil, fmt.Errorf("error parsing minimum number of approvals: %w", err)
}
}

if minimumApprovals > len(approvers) {
return nil, fmt.Errorf("error: minimum required approvals (%d) is greater than the total number of approvers (%d)", minimumApprovals, len(approvers))
return nil, nil, fmt.Errorf("error: minimum required approvals (%d) is greater than the total number of approvers (%d)", minimumApprovals, len(approvers))
}

return approvers, nil
return approvers, disallowedUsers, nil
}

func expandGroupFromUser(client *github.Client, org, userOrTeam string, workflowInitiator string, shouldExcludeWorkflowInitiator bool) []string {
Expand Down
17 changes: 5 additions & 12 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
module github.com/trstringer/manual-approval
module github.com/Delphia/manual-approval

go 1.17
go 1.22

require (
github.com/google/go-github/v43 v43.0.0
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
github.com/google/go-github/v61 v61.0.0
golang.org/x/oauth2 v0.19.0
)

require (
github.com/golang/protobuf v1.4.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.25.0 // indirect
)
require github.com/google/go-querystring v1.1.0 // indirect
Loading