Compare commits

..

1 commit

Author SHA1 Message Date
SnskArora
3ef322c701
Updates for release 1.10.0 2025-05-27 01:32:20 +05:30
17 changed files with 462 additions and 379 deletions

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,29 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
- [ ] I have searched through the current issues and did not find any that were related.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
- [ ] I have searched through the current feature requests and did not find any that were related.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

7
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10 # default: 5

34
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- '**/*.md'
pull_request:
branches:
- main
paths-ignore:
- '**/*.md'
jobs:
ci:
name: CI
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Refresh module hashsums
run: make tidy
- name: Build
run: make build
env:
VERSION: latest
- name: Test
run: make test
- name: Lint
run: make lint

39
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: "Code Analysis"
on:
push:
branches:
- "main"
pull_request:
permissions:
contents: read
jobs:
CodeQL-Build:
if: github.repository == 'trstringer/manual-approval'
permissions:
actions: read
contents: read
security-events: write
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Golang
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View file

@ -27,8 +27,8 @@ Thank you for your interest in contributing to this project! We appreciate your
git push origin feature-branch-name
```
3. Open a PR on StackitGit, describing your changes clearly.
4. If your PR is related to an issue, use the [StackitGit key words](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
3. Open a PR on GitHub, describing your changes clearly.
4. If your PR is related to an issue, use the [GitHub key words](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
This will ensure that the related issue will automatically close when your change is merged.
5. Address any feedback and update your PR as necessary.

View file

@ -1,10 +1,10 @@
FROM golang:1.25 AS builder
FROM golang:1.17 AS builder
COPY . /var/app
WORKDIR /var/app
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o app .
FROM alpine:3.23
FROM alpine:3.14
LABEL org.opencontainers.image.source=https://github.com/trstringer/manual-approval
RUN apk update && apk add ca-certificates
COPY --from=builder /var/app/app /var/app/app

View file

@ -1,5 +1,5 @@
IMAGE_REPO=ghcr.io/trstringer/manual-approval
TARGET_PLATFORM=linux/amd64,linux/arm64,linux/arm/v8
TARGET_PLATFORM=linux/amd64
.PHONY: tidy
tidy:
@ -11,18 +11,15 @@ build:
echo "VERSION is required"; \
exit 1; \
fi
docker build -t $(IMAGE_REPO):$(VERSION) .
docker build --platform $(TARGET_PLATFORM) -t $(IMAGE_REPO):$$VERSION .
.PHONY: build_push
build_push:
.PHONY: push
push:
@if [ -z "$(VERSION)" ]; then \
echo "VERSION is required"; \
exit 1; \
fi
docker buildx create --use --name mybuilder
docker buildx build --push --platform $(TARGET_PLATFORM) -t $(IMAGE_REPO):$(VERSION) .
docker buildx rm mybuilder
docker push $(IMAGE_REPO):$(VERSION)
.PHONY: test
test:
@ -30,4 +27,4 @@ test:
.PHONY: lint
lint:
docker run --rm -v $$(pwd):/app -w /app golangci/golangci-lint:v2.1.6 golangci-lint run -v
docker run --rm -v $$(pwd):/app -w /app golangci/golangci-lint:v1.46.2 golangci-lint run -v

138
README.md
View file

@ -1,13 +1,13 @@
# Manual Workflow Approval
Pause a StackitGit Actions workflow and require manual approval from one or more approvers before continuing.
[![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 very common feature for a deployment or release pipeline.
Pause a GitHub Actions workflow and require manual approval from one or more approvers before continuing.
*Note: This approval duration is subject to the broader 4 hours timeout for a workflow, as well as usage costs. So keep that in mind when figuring out how quickly an approver must respond. See [limitations](#limitations) for more information.*
This is a very common feature for a deployment or release pipeline, and while [this functionality is available from GitHub](https://docs.github.com/en/actions/managing-workflow-runs/reviewing-deployments), it requires the use of environments and if you want to use this for private repositories, then you need GitHub Enterprise. This action provides manual approval without the use of environments, and is freely available to use on private repositories.
*Note: A cheaper but less automatic solution using split jobs and workflow dispatcher can be seen in [here](workflow_dispatcher.md)
*Note: This approval duration is subject to the broader 35 day timeout for a workflow, as well as usage costs. So keep that in mind when figuring out how quickly an approver must respond. See [limitations](#limitations) for more information.*
The way this action works is the following:
@ -21,49 +21,33 @@ The way this action works is the following:
These are case insensitive with optional punctuation either a period or an exclamation mark.
In all cases, `manual-approval` will close the initial StackitGit issue.
In all cases, `manual-approval` will close the initial GitHub issue.
## Usage
```yaml
steps:
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
- uses: trstringer/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: user1,user2,org-team1
minimum-approvals: 1
issue-title: "Deploying v1.3.5 to prod from staging"
issue-body: "Please approve or deny the deployment of version v1.3.5."
issue-body-file-path: relative/file_path/wrt/repo/root
exclude-workflow-initiator-as-approver: false
fail-on-denial: true
additional-approved-words: ''
additional-denied-words: ''
polling-interval-seconds: 10
```
* `approvers` is a comma-delimited list of all required approvers. An approver can either be a user or an org team. (*Note: Required approvers must have the ability to be set as approvers in the repository. If you add an approver that doesn't have this permission then you would receive an HTTP/402 Validation Failed error when running this action*)
* `minimum-approvals` is an integer that sets the minimum number of approvals required to progress the workflow. Defaults to ALL approvers.
* `issue-title` is a string that will be used as the title of the approval-issue.
* `issue-body` is a string that will be added as comments on the approval-issue.
* `issue-body-file-path` is a string that is the file path, this file's content will be added as comments on the approval-issue. If both issue-body and issue-body-file-path are given, then the file contents are considered for issue comments.
* `issue-title` is a string that will be appended to the title of the issue.
* `issue-body` is a string that will be prepended to the body of the issue.
* `exclude-workflow-initiator-as-approver` is a boolean that indicates if the workflow initiator (determined by the `GITHUB_ACTOR` environment variable) should be filtered from the final list of approvers. This is optional and defaults to `false`. Set this to `true` to prevent users in the `approvers` list from being able to self-approve workflows.
* `fail-on-denial` is a boolean that indicates if the workflow should fail if any approver denies the approval. This is optional and defaults to `true`. Set this to `false` to allow the workflow to continue if any approver denies the approval.
* `additional-approved-words` is a comma separated list of strings to expand the dictionary of words that indicate approval. This is optional and defaults to an empty string.
* `additional-denied-words` is a comma separated list of strings to expand the dictionary of words that indicate denial. This is optional and defaults to an empty string.
* `polling-interval-seconds` is an integer that sets the number of seconds to wait between polling the StackitGit API for approval status. This is optional and defaults to `10` seconds. Increase this value if you want to reduce API calls, or decrease it for faster response times.
> [!Note]
> 1. If You are using issue-body-file-path then please make sure the file is reachable; for example, if the file is in your repo, then please checkout to your repo in the same job as the approval issue.
> 2. When using issue-body, the content string is passed as an arguent which is limited by StackitGit at 10kb. For content >= 10kb, use files for passing the issue body.
> [!CAUTION]
> When using a file please make sure that the file size remains under 125 KB (a safe limit, to stay under the threshold). If the file size is huge, then the file content will be broken into multiple chunks, each representing an issue comment. With this many API requests the API rate limit is exceeded, and the actions will be temporarily blocked, resulting in an error message like: `403 You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.`
>
> 5 MB is a crude estimate, as secondary rate limits apply to a user, so your user (usually the bot using an app token for authentication) will not be able to do anything for some time. Primary limits might still reset quickly, but secondary limits will need some cool-off time.
The file method works unless the file itself is so big that after breaking it into chunks of 65k characters, it exceeds the API limit.
### Outputs
@ -73,7 +57,7 @@ The file method works unless the file itself is so big that after breaking it in
```yaml
steps:
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
- uses: trstringer/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: user1,user2,org-team1
@ -86,20 +70,20 @@ steps:
target-repository: repository-name
target-repository-owner: owner-id
```
- If either of `target-repository` or `target-repository-owner` is missing or is an empty string, then the issue will be created in the same repository where this step is used.
- if either of `target-repository` or `target-repository-owner` is missing or is an empty string then the issue will be created in the same repository where this step is used.
### Using Custom Words
StackitGit has a rich library of emojis, and these all work in additional approved words or denied words. Some values StackitGit will store in their text version - i.e. `:shipit:`. Other emojis, StackitGit will store in their Unicode emoji form, like âś….
For a seamless experience, it is recommended that you add the custom words to a StackitGit comment, and then copy it back out of the comment into your actions configuration YAML.
GitHub has a rich library of emojis, and these all work in additional approved words or denied words. Some values GitHub will store in their text version - i.e. `:shipit:`. Other emojis, GitHub will store in their unicode emoji form, like âś….
For a seamless experience, it is recommended that you add the custom words to a GitHub comment, and then copy it back out of the comment into your actions configuration yaml.
## Org team approver
If you want to have `approvers` set to an org team, then you need to take a different approach. The default [StackitGit Actions automatic token](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) does not have the necessary permissions to list out team members. If you would like to use this, then you need to generate a token from a StackitGit App with the correct set of permissions. Apart from this, the StackitGit app will also need the Issue: Read & Write role.
If you want to have `approvers` set to an org team, then you need to take a different approach. The default [GitHub Actions automatic token](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) does not have the necessary permissions to list out team members. If you would like to use this then you need to generate a token from a GitHub App with the correct set of permissions.
Create an Organization StackitGit App with **read-only access to organization members**. Once the app is created, add a repo secret with the app ID. In the StackitGit App settings, generate a private key and add that as a secret in the repo as well. You can get the app token by using the [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token) StackitGit Action:
Create a GitHub App with **read-only access to organization members**. Once the app is created, add a repo secret with the app ID. In the GitHub App settings, generate a private key and add that as a secret in the repo as well. You can get the app token by using the [`tibdex/github-app-token`](https://github.com/tibdex/github-app-token) GitHub Action:
*Note: The StackitGit App tokens expire after 1 hour, which implies the duration for the approval cannot exceed 60 minutes, or the job will fail due to bad credentials. See [docs](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app).*
*Note: The GitHub App tokens expire after 1 hour which implies duration for the approval cannot exceed 60 minutes or the job will fail due to bad credentials. See [docs](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app).*
```yaml
jobs:
@ -108,12 +92,12 @@ jobs:
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v2
uses: tibdex/github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for approval
uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
uses: trstringer/manual-approval@v1
with:
secret: ${{ steps.generate_token.outputs.token }}
approvers: myteam
@ -124,22 +108,20 @@ jobs:
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.
> [!Note]
>
> The `timeout-minutes` option has been removed from the `manual-approval` inputs, as it did nothing and incorrectly assured users that they were in fact
> **Note:** The `timeout-minutes` option has been removed from the `manual-approval` inputs, as it did nothing and incorrectly assured users that they were in fact
> getting timeout behavior. Please use one of the below two approaches instead.
>
> If you are currently using `timeout-minutes` as a `manual-approval` input, you may see a warning, but this will not break your action.
For instance, if you want your manual approval step to timeout after an hour, you could do the following:
For instance, if you want your manual approval step to timeout after an hour you could do the following:
```yaml
jobs:
approval:
steps:
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
- uses: trstringer/manual-approval@v1
timeout-minutes: 60
# ...
...
```
or
```yaml
@ -147,14 +129,80 @@ jobs:
approval:
timeout-minutes: 10
steps:
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
- uses: trstringer/manual-approval@v1
```
## Permissions
For the action to create a new issue in your project, please ensure that the action has write permissions on issues. You may have to add the following to your workflow:
```yaml
permissions:
issues: write
```
For more information on permissions, please look at the [GitHub documentation](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs).
## Limitations
* While the workflow is paused, it will still continue to consume a concurrent job allocation out of the max concurrent job of 20.
* While the workflow is paused, it will still continue to consume a concurrent job allocation out of the [max concurrent jobs](https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration#usage-limits).
* A paused job is still running compute/instance/virtual machine and will continue to incur costs.
* Expirations (also mentioned elsewhere in this document):
* A job (including a paused job) will be failed after 4 hours, and a workflow will be failed after 35 days.
* StackitGit App tokens expire after 1 hour, which implies the duration for the approval cannot exceed 60 minutes, or the job will fail due to bad credentials.
* A job (including a paused job) will be failed [after 6 hours, and a workflow will be failed after 35 days](https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration#usage-limits).
* GitHub App tokens expire after 1 hour which implies duration for the approval cannot exceed 60 minutes or the job will fail due to bad credentials. See [docs](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app)
## Development
### 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
```
Build the image:
```shell
VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/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:
```shell
VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/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
```
Then to test out the image, run a workflow specifying your dev branch:
```yaml
- name: Wait for approval
uses: your-github-user/manual-approval@your-dev-branch
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: trstringer
```
For `uses`, this should point to your repo and dev branch.
*Note: To test out the action that uses an approver that is an org team, refer to the [org team approver](#org-team-approver) section for instructions.*
### Create a release
1. Build the new version's image: `$ VERSION=1.7.0 make build`
2. Push the new image: `$ VERSION=1.7.0 make push`
3. Create a release branch and modify `action.yaml` to point to the new image
4. Open and merge a PR to add these changes to the default branch
5. Make sure to fetch the new changes into your local repo: `$ git checkout main && git fetch origin && git merge origin main`
6. Delete the `v1` tag locally and remotely: `$ git tag -d v1 && git push --delete origin v1`
7. Create and push new tags: `$ git tag v1.7.0 && git tag v1 && git push origin --tags`
8. Create the GitHub project release

View file

@ -19,37 +19,28 @@ inputs:
issue-body:
description: The custom body for the issue
required: false
issue-body-file-path:
description: The file path to a custom body for the issue
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
description: Whether or not to filter out the user who initiated the workflow as an approver if they are in the approvers list
required: false
default: "false"
default: 'false'
additional-approved-words:
description: Comma separated list of words that can be used to approve beyond
the defaults.
description: Comma separated list of words that can be used to approve beyond the defaults.
required: false
default: ""
default: ''
additional-denied-words:
description: Comma separated list of words that can be used to deny beyond the defaults.
required: false
default: ""
default: ''
target-repository-owner:
description: Owner of the repository in which the issue will be created.
default: ""
default: ''
target-repository:
description: Name of the repository in which the issue will be created.
default: ""
default: ''
fail-on-denial:
description: Whether or not to fail the workflow if the approval is denied
required: false
default: "true"
polling-interval-seconds:
description: Number of seconds to wait between polling StackitGit API for approval status
required: false
default: "10"
default: 'true'
outputs:
issue-number:
description: The number of the issue created
@ -59,4 +50,4 @@ outputs:
description: The status of the approval ("approved" or "denied")
runs:
using: docker
image: Dockerfile
image: docker://ghcr.io/trstringer/manual-approval:1.10.0

View file

@ -7,16 +7,16 @@ import (
"regexp"
"strings"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/google/go-github/v43/github"
)
type approvalEnvironment struct {
forgejoClient *forgejo.Client
client *github.Client
repoFullName string
repo string
repoOwner string
runID int
approvalIssue *forgejo.Issue
approvalIssue *github.Issue
approvalIssueNumber int
issueTitle string
issueBody string
@ -27,7 +27,7 @@ type approvalEnvironment struct {
failOnDenial bool
}
func newApprovalEnvironment(forgejoClient *forgejo.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) {
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) {
repoOwnerAndName := strings.Split(repoFullName, "/")
if len(repoOwnerAndName) != 2 {
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
@ -35,7 +35,7 @@ func newApprovalEnvironment(forgejoClient *forgejo.Client, repoFullName, repoOwn
repo := repoOwnerAndName[1]
return &approvalEnvironment{
forgejoClient: forgejoClient,
client: client,
repoFullName: repoFullName,
repo: repo,
repoOwner: repoOwner,
@ -51,14 +51,14 @@ func newApprovalEnvironment(forgejoClient *forgejo.Client, repoFullName, repoOwn
}
func (a approvalEnvironment) runURL() string {
serverUrl := os.Getenv("GITHUB_SERVER_URL")
if serverUrl == "" {
serverUrl = "https://github.com"
baseUrl := a.client.BaseURL.String()
if strings.Contains(baseUrl, "github.com") {
baseUrl = "https://github.com/"
}
return fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimRight(serverUrl, "/"), a.repoFullName, a.runID)
return fmt.Sprintf("%s%s/actions/runs/%d", baseUrl, a.repoFullName, a.runID)
}
func (a *approvalEnvironment) createApprovalIssue(_ context.Context) error {
func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
if a.issueTitle != "" {
@ -85,11 +85,7 @@ func (a *approvalEnvironment) createApprovalIssue(_ context.Context) error {
formatAcceptedWords(deniedWords),
)
if a.issueBody != "" {
issueBody = fmt.Sprintf("%s\n\n%s", a.issueBody, issueBody)
} else {
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
}
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
var err error
fmt.Printf(
@ -100,20 +96,27 @@ func (a *approvalEnvironment) createApprovalIssue(_ context.Context) error {
a.issueApprovers,
issueBody,
)
created, _, err := a.forgejoClient.CreateIssue(a.targetRepoOwner, a.targetRepoName, forgejo.CreateIssueOption{
Title: issueTitle,
Body: issueBody,
Assignees: a.issueApprovers,
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.targetRepoOwner, a.targetRepoName, &github.IssueRequest{
Title: &issueTitle,
Body: &issueBody,
Assignees: &a.issueApprovers,
})
if err != nil {
return err
}
a.approvalIssueNumber = a.approvalIssue.GetNumber()
a.approvalIssue = created
a.approvalIssueNumber = int(created.Index)
bodyChunks := splitLongString(a.issueBody)
for _, chunk := range bodyChunks {
_, _, err = a.client.Issues.CreateComment(ctx, a.targetRepoOwner, a.targetRepoName, *a.approvalIssue.Number, &github.IssueComment{
Body: &chunk,
})
if err != nil {
return fmt.Errorf("failed to add comment chunk to issue: %w", err)
}
}
fmt.Printf("Issue created: %s\n", a.approvalIssue.HTMLURL)
fmt.Printf("Issue created: %s\n", a.approvalIssue.GetHTMLURL())
return nil
}
@ -124,13 +127,11 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool,
}
f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return false, err
}
defer func() {
_ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails.
}()
defer f.Close()
var pairs []string
@ -157,47 +158,44 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool,
return true, nil
}
func approvalFromComments(comments []*forgejo.Comment, approvers []string, minimumApprovals int) (approvalStatus, error) {
approvedUsers := []string{}
deniedUsers := []string{}
// If minimum approvals is not set, default to all approvers
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
remainingApprovers := make([]string, len(approvers))
copy(remainingApprovers, approvers)
if minimumApprovals == 0 {
minimumApprovals = len(approvers)
}
for _, comment := range comments {
commenter := comment.Poster.UserName
if approversIndex(approvers, commenter) == -1 {
commentUser := comment.User.GetLogin()
approverIdx := approversIndex(remainingApprovers, commentUser)
if approverIdx < 0 {
continue
}
isApproval, err := isApproved(comment.Body)
commentBody := comment.GetBody()
isApprovalComment, err := isApproved(commentBody)
if err != nil {
return "", err
return approvalStatusPending, err
}
isDenial, err := isDenied(comment.Body)
if isApprovalComment {
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
return approvalStatusApproved, nil
}
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
continue
}
isDenialComment, err := isDenied(commentBody)
if err != nil {
return "", err
return approvalStatusPending, err
}
if isApproval {
approvedUsers = append(approvedUsers, commenter)
} else if isDenial {
deniedUsers = append(deniedUsers, commenter)
if isDenialComment {
return approvalStatusDenied, nil
}
}
approvedUsers = deduplicateUsers(approvedUsers)
deniedUsers = deduplicateUsers(deniedUsers)
if len(deniedUsers) > 0 {
return approvalStatusDenied, nil
}
if len(approvedUsers) >= minimumApprovals {
return approvalStatusApproved, nil
}
return approvalStatusPending, nil
}
@ -279,3 +277,50 @@ func splitLongLine(line string, maxL int) ([]string, bool) {
}
return result, true
}
func splitLongString(input string) []string {
maxLength := 65536
var result []string
lines := strings.Split(input, "\n")
currentChunk := strings.Builder{}
currentLength := 0
for i, line := range lines {
lineLength := len(line)
if i < len(lines)-1 {
lineLength++
}
if currentLength+lineLength > maxLength {
if currentChunk.Len() > 0 {
result = append(result, currentChunk.String())
currentChunk.Reset()
currentLength = 0
}
}
lineSplit, isLongLine := splitLongLine(line, maxLength)
if isLongLine {
if currentChunk.Len() > 0 {
result = append(result, currentChunk.String())
currentChunk.Reset()
}
result = append(result, lineSplit[:len(lineSplit)-1]...)
currentChunk.WriteString(lineSplit[len(lineSplit)-1])
currentLength = len(lineSplit[len(lineSplit)-1])
} else {
currentChunk.WriteString(line)
currentLength += lineLength
}
if i < len(lines)-1 {
currentChunk.WriteString("\n")
}
}
if currentChunk.Len() > 0 {
result = append(result, currentChunk.String())
}
return result
}

View file

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/google/go-github/v43/github"
)
func TestApprovalFromComments(t *testing.T) {
@ -21,17 +21,17 @@ func TestApprovalFromComments(t *testing.T) {
testCases := []struct {
name string
comments []*forgejo.Comment
comments []*github.IssueComment
approvers []string
minimumApprovals int
expectedStatus approvalStatus
}{
{
name: "single_approver_single_comment_approved",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyApproved,
User: &github.User{Login: &login1},
Body: &bodyApproved,
},
},
approvers: []string{login1},
@ -39,10 +39,10 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "single_approver_single_comment_denied",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyDenied,
User: &github.User{Login: &login1},
Body: &bodyDenied,
},
},
approvers: []string{login1},
@ -50,10 +50,10 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "single_approver_single_comment_pending",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyPending,
User: &github.User{Login: &login1},
Body: &bodyPending,
},
},
approvers: []string{login1},
@ -61,14 +61,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "single_approver_multi_comment_approved",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyPending,
User: &github.User{Login: &login1},
Body: &bodyPending,
},
{
Poster: &forgejo.User{UserName: login1},
Body: bodyApproved,
User: &github.User{Login: &login1},
Body: &bodyApproved,
},
},
approvers: []string{login1},
@ -76,14 +76,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_approved",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyApproved,
User: &github.User{Login: &login1},
Body: &bodyApproved,
},
{
Poster: &forgejo.User{UserName: login2},
Body: bodyApproved,
User: &github.User{Login: &login2},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2},
@ -91,14 +91,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_mixed",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyPending,
User: &github.User{Login: &login1},
Body: &bodyPending,
},
{
Poster: &forgejo.User{UserName: login2},
Body: bodyApproved,
User: &github.User{Login: &login2},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2},
@ -106,14 +106,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_denied",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyDenied,
User: &github.User{Login: &login1},
Body: &bodyDenied,
},
{
Poster: &forgejo.User{UserName: login2},
Body: bodyApproved,
User: &github.User{Login: &login2},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2},
@ -121,14 +121,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_minimum_one_approval",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyPending,
User: &github.User{Login: &login1},
Body: &bodyPending,
},
{
Poster: &forgejo.User{UserName: login2},
Body: bodyApproved,
User: &github.User{Login: &login2},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2},
@ -137,14 +137,14 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_minimum_two_approvals",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyApproved,
User: &github.User{Login: &login1},
Body: &bodyApproved,
},
{
Poster: &forgejo.User{UserName: login2},
Body: bodyApproved,
User: &github.User{Login: &login2},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2, login3},
@ -153,10 +153,10 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "multi_approver_approvals_less_than_minimum",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1},
Body: bodyApproved,
User: &github.User{Login: &login1},
Body: &bodyApproved,
},
},
approvers: []string{login1, login2, login3},
@ -165,10 +165,10 @@ func TestApprovalFromComments(t *testing.T) {
},
{
name: "single_approver_single_comment_approved_case_insensitive",
comments: []*forgejo.Comment{
comments: []*github.IssueComment{
{
Poster: &forgejo.User{UserName: login1u},
Body: bodyApproved,
User: &github.User{Login: &login1u},
Body: &bodyApproved,
},
},
approvers: []string{login1},
@ -467,9 +467,9 @@ func TestSaveOutput(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Setenv("GITHUB_OUTPUT", testCase.env_github_output)
os.Setenv("GITHUB_OUTPUT", testCase.env_github_output)
a := approvalEnvironment{
forgejoClient: nil,
client: nil,
repoFullName: "",
repo: "",
repoOwner: "",
@ -481,10 +481,7 @@ func TestSaveOutput(t *testing.T) {
minimumApprovals: 0,
}
if err := os.Remove(testCase.env_github_output); err != nil && !os.IsNotExist(err) {
t.Fatalf("failed to remove file: %v", err)
}
os.Remove(testCase.env_github_output)
actual, err := a.SetActionOutputs(nil)
if err != nil {

View file

@ -1,15 +1,16 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/google/go-github/v43/github"
)
func retrieveApprovers(client *forgejo.Client, repoOwner string) ([]string, error) {
func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error) {
workflowInitiator := os.Getenv(envVarWorkflowInitiator)
shouldExcludeWorkflowInitiatorRaw := os.Getenv(envVarExcludeWorkflowInitiatorAsApprover)
shouldExcludeWorkflowInitiator, parseBoolErr := strconv.ParseBool(shouldExcludeWorkflowInitiatorRaw)
@ -27,7 +28,7 @@ func retrieveApprovers(client *forgejo.Client, repoOwner string) ([]string, erro
for _, approverUser := range requiredApprovers {
expandedUsers := expandGroupFromUser(client, repoOwner, approverUser, workflowInitiator, shouldExcludeWorkflowInitiator)
if len(expandedUsers) > 0 {
if expandedUsers != nil {
approvers = append(approvers, expandedUsers...)
} else if strings.EqualFold(workflowInitiator, approverUser) && shouldExcludeWorkflowInitiator {
fmt.Printf("Not adding user '%s' as an approver as they are the workflow initiator\n", approverUser)
@ -55,37 +56,23 @@ func retrieveApprovers(client *forgejo.Client, repoOwner string) ([]string, erro
return approvers, nil
}
func expandGroupFromUser(client *forgejo.Client, org string, userOrTeam string, workflowInitiator string, shouldExcludeWorkflowInitiator bool) []string {
func expandGroupFromUser(client *github.Client, org, userOrTeam string, workflowInitiator string, shouldExcludeWorkflowInitiator bool) []string {
fmt.Printf("Attempting to expand user %s/%s as a group (may not succeed)\n", org, userOrTeam)
// Try to find a team with this name in the org
teams, _, err := client.SearchOrgTeams(org, &forgejo.SearchTeamsOptions{Query: userOrTeam})
// GitHub replaces periods in the team name with hyphens. If a period is
// passed to the request it would result in a 404. So we need to replace
// and occurrences with a hyphen.
formattedUserOrTeam := strings.ReplaceAll(userOrTeam, ".", "-")
users, _, err := client.Teams.ListTeamMembersBySlug(context.Background(), org, formattedUserOrTeam, &github.TeamListTeamMembersOptions{})
if err != nil {
fmt.Printf("Error searching teams: %v\n", err)
return nil
}
var team *forgejo.Team
for _, t := range teams {
if strings.EqualFold(t.Name, userOrTeam) {
team = t
break
}
}
if team == nil {
return nil
}
users, _, err := client.ListTeamMembers(team.ID, forgejo.ListTeamMembersOptions{})
if err != nil {
fmt.Printf("Error listing team members: %v\n", err)
fmt.Printf("%v\n", err)
return nil
}
userNames := make([]string, 0, len(users))
for _, user := range users {
userName := user.UserName
userName := user.GetLogin()
if strings.EqualFold(userName, workflowInitiator) && shouldExcludeWorkflowInitiator {
fmt.Printf("Not adding user '%s' from group '%s' as an approver as they are the workflow initiator\n", userName, userOrTeam)
} else {

View file

@ -7,7 +7,7 @@ import (
)
const (
defaultPollingInterval time.Duration = 10 * time.Second
pollingInterval time.Duration = 10 * time.Second
envVarRepoFullName string = "GITHUB_REPOSITORY"
envVarRunID string = "GITHUB_RUN_ID"
@ -18,14 +18,12 @@ const (
envVarMinimumApprovals string = "INPUT_MINIMUM-APPROVALS"
envVarIssueTitle string = "INPUT_ISSUE-TITLE"
envVarIssueBody string = "INPUT_ISSUE-BODY"
envVarIssueBodyFilePath string = "INPUT_ISSUE-BODY-FILE-PATH"
envVarExcludeWorkflowInitiatorAsApprover string = "INPUT_EXCLUDE-WORKFLOW-INITIATOR-AS-APPROVER"
envVarAdditionalApprovedWords string = "INPUT_ADDITIONAL-APPROVED-WORDS"
envVarAdditionalDeniedWords string = "INPUT_ADDITIONAL-DENIED-WORDS"
envVarFailOnDenial string = "INPUT_FAIL-ON-DENIAL"
envVarTargetRepoOwner string = "INPUT_TARGET-REPOSITORY-OWNER"
envVarTargetRepo string = "INPUT_TARGET-REPOSITORY"
envVarPollingIntervalSeconds string = "INPUT_POLLING-INTERVAL-SECONDS"
)
var (

42
go.mod
View file

@ -1,35 +1,17 @@
module github.com/trstringer/manual-approval
go 1.25
require codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0
go 1.17
require (
github.com/42wim/httpsig v1.2.3 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-openapi/errors v0.22.6 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
github.com/google/go-github/v43 v43.0.0
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
)
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
)

126
main.go
View file

@ -9,49 +9,38 @@ import (
"strings"
"time"
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/google/go-github/v43/github"
"golang.org/x/oauth2"
)
// patchIssueState closes or otherwise updates an issue's state without decoding
// the response body into a github.Issue. go-github's Issues.Edit decodes the
// response into github.Issue, which fails against StackitGit because its issue
// response embeds "repository.owner" as a plain string rather than a User object.
func patchIssueState(_ context.Context, client *forgejo.Client, owner, repo string, number int, state string) error {
issueState := forgejo.StateType(state)
_, _, err := client.EditIssue(owner, repo, int64(number), forgejo.EditIssueOption{
State: &issueState,
})
return err
}
func handleInterrupt(ctx context.Context, client *forgejo.Client, apprv *approvalEnvironment) {
func handleInterrupt(ctx context.Context, client *github.Client, apprv *approvalEnvironment) {
newState := "closed"
closeComment := "Workflow cancelled, closing issue."
fmt.Println(closeComment)
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
Body: &closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
_, _, err = client.Issues.Edit(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
if err != nil {
fmt.Printf("error closing issue: %v\n", err)
return
}
}
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *forgejo.Client, pollingInterval time.Duration) chan int {
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client) chan int {
channel := make(chan int)
go func() {
for {
comments, _, err := client.ListIssueComments(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.ListIssueCommentOptions{})
comments, _, err := client.Issues.ListComments(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueListCommentsOptions{})
if err != nil {
fmt.Printf("error getting comments: %v\n", err)
channel <- 1
close(channel)
return
}
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
@ -59,32 +48,29 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
fmt.Printf("error getting approval from comments: %v\n", err)
channel <- 1
close(channel)
return
}
fmt.Printf("Workflow status: %s\n", approved)
switch approved {
case approvalStatusApproved:
newState := "closed"
closeComment := fmt.Sprintf("The required number of approvals (%d) has been met; continuing workflow and closing this issue.", apprv.minimumApprovals)
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
Body: &closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
close(channel)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
_, _, err = client.Issues.Edit(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
if err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
close(channel)
return
}
channel <- 0
fmt.Println("Workflow manual approval completed")
close(channel)
return
case approvalStatusDenied:
newState := "closed"
closeComment := "Request denied. Closing issue "
@ -95,24 +81,22 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
}
closeComment += " workflow."
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
Body: closeComment,
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
Body: &closeComment,
})
if err != nil {
fmt.Printf("error commenting on issue: %v\n", err)
channel <- 1
close(channel)
return
}
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
_, _, err = client.Issues.Edit(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
if err != nil {
fmt.Printf("error closing issue: %v\n", err)
channel <- 1
close(channel)
return
}
channel <- 1
close(channel)
return
}
time.Sleep(pollingInterval)
@ -121,18 +105,23 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
return channel
}
func newForgejoClient() (*forgejo.Client, error) {
func newGithubClient(ctx context.Context) (*github.Client, error) {
token := os.Getenv(envVarToken)
serverUrl := os.Getenv("GITHUB_SERVER_URL")
if serverUrl == "" {
return nil, fmt.Errorf("GITHUB_SERVER_URL must be set for StackitGit client")
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
tc := oauth2.NewClient(ctx, ts)
client, err := forgejo.NewClient(serverUrl, forgejo.SetToken(token))
if err != nil {
return nil, fmt.Errorf("failed to create StackitGit client: %w", err)
serverUrl, serverUrlPresent := os.LookupEnv("GITHUB_SERVER_URL")
apiUrl, apiUrlPresent := os.LookupEnv("GITHUB_API_URL")
if serverUrlPresent {
if !apiUrlPresent {
apiUrl = serverUrl
}
return github.NewEnterpriseClient(apiUrl, serverUrl, tc)
}
return client, nil
return github.NewClient(tc), nil
}
func validateInput() error {
@ -187,13 +176,13 @@ func main() {
}
ctx := context.Background()
forgejoClient, err := newForgejoClient()
client, err := newGithubClient(ctx)
if err != nil {
fmt.Printf("error connecting to StackitGit server: %v\n", err)
fmt.Printf("error connecting to server: %v\n", err)
os.Exit(1)
}
approvers, err := retrieveApprovers(forgejoClient, repoOwner)
approvers, err := retrieveApprovers(client, repoOwner)
if err != nil {
fmt.Printf("error retrieving approvers: %v\n", err)
os.Exit(1)
@ -209,33 +198,8 @@ func main() {
}
}
pollingInterval := defaultPollingInterval
pollingIntervalSecondsRaw := os.Getenv(envVarPollingIntervalSeconds)
if pollingIntervalSecondsRaw != "" {
pollingIntervalSeconds, err := strconv.Atoi(pollingIntervalSecondsRaw)
if err != nil {
fmt.Printf("error parsing polling interval: %v\n", err)
os.Exit(1)
}
if pollingIntervalSeconds <= 0 {
fmt.Printf("error: polling interval must be greater than 0\n")
os.Exit(1)
}
pollingInterval = time.Duration(pollingIntervalSeconds) * time.Second
}
issueTitle := os.Getenv(envVarIssueTitle)
var issueBody string
if os.Getenv(envVarIssueBodyFilePath) != "" {
fileContents, err := os.ReadFile(os.Getenv(envVarIssueBodyFilePath))
if err != nil {
fmt.Printf("error reading issue body file: %v\n", err)
os.Exit(1)
}
issueBody = string(fileContents)
} else {
issueBody = os.Getenv(envVarIssueBody)
}
issueBody := os.Getenv(envVarIssueBody)
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := 0
if minimumApprovalsRaw != "" {
@ -246,7 +210,7 @@ func main() {
}
}
apprv, err := newApprovalEnvironment(forgejoClient, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
if err != nil {
fmt.Printf("error creating approval environment: %v\n", err)
os.Exit(1)
@ -254,38 +218,38 @@ func main() {
err = apprv.createApprovalIssue(ctx)
if err != nil {
fmt.Printf("error creating issue: %v\n", err)
fmt.Printf("error creating issue: %v", err)
os.Exit(1)
}
outputs := map[string]string{
outputs := map[string]string {
"issue-number": fmt.Sprintf("%d", apprv.approvalIssueNumber),
"issue-url": apprv.approvalIssue.HTMLURL,
"issue-url": apprv.approvalIssue.GetHTMLURL(),
}
_, err = apprv.SetActionOutputs(outputs)
if err != nil {
fmt.Printf("error saving output: %v\n", err)
fmt.Printf("error saving output: %v", err)
os.Exit(1)
}
killSignalChannel := make(chan os.Signal, 1)
signal.Notify(killSignalChannel, os.Interrupt)
commentLoopChannel := newCommentLoopChannel(ctx, apprv, forgejoClient, pollingInterval)
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client)
select {
case exitCode := <-commentLoopChannel:
approvalStatus := ""
if !failOnDenial && exitCode == 1 {
if (!failOnDenial && exitCode == 1) {
approvalStatus = "denied"
exitCode = 0
} else if exitCode == 1 {
} else if (exitCode == 1) {
approvalStatus = "denied"
} else {
approvalStatus = "approved"
}
outputs := map[string]string{
outputs := map[string]string {
"approval-status": approvalStatus,
}
if _, err := apprv.SetActionOutputs(outputs); err != nil {
@ -294,7 +258,7 @@ func main() {
}
os.Exit(exitCode)
case <-killSignalChannel:
handleInterrupt(ctx, forgejoClient, apprv)
handleInterrupt(ctx, client, apprv)
os.Exit(1)
}
}

View file

@ -1,57 +0,0 @@
# StackitGit Actions: Manual Approvals Guide
While StackitGit Actions does not currently feature native environment protection rules that pause a running workflow for a UI approval, the most effective and resource-efficient workaround is to split your CI/CD pipeline into separate workflows using the `workflow_dispatch` event.
> **Resource Efficiency (Cost Savings):**
> Unlike workarounds that use third-party actions to poll for comments on an Issue, splitting workflows is **significantly cheaper and more efficient**. Pause-and-poll methods keep the original workflow active, *holding the runner hostage* and consuming compute minutes for hours or days while waiting for an approval. By splitting workflows, the runner is immediately freed after the build phase. A new runner is only provisioned when the deployment is explicitly approved.
## Step 1: The Automated Build & Test Workflow
Create your primary workflow that runs automatically on every push or pull request. This workflow handles everything up to the point of deployment.
```yaml
name: 1. Build and Test
on: [push]
jobs:
build:
runs-on: stackit-ubuntu-22
steps:
- uses: actions/checkout@v4
- name: Run tests
run: echo "Testing the code..."
- name: Build artifact
run: echo "Building artifact..."
# Upload artifacts here for the deploy workflow to download
```
## Step 2: The Manual Deployment Workflow
Create a second workflow triggered *only* by `workflow_dispatch`. This generates a "Run Workflow" button in the StackitGit UI, serving as your manual approval gate.
```yaml
name: 2. Manual Production Deploy
on:
workflow_dispatch:
inputs:
version:
description: 'Version or Branch to deploy'
required: true
default: 'main'
jobs:
deploy:
runs-on: stackit-ubuntu-22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.version }}
- name: Deploy to Production
run: echo "Deploying version ${{ github.event.inputs.version }}..."
```
## Workflow Execution
1. A developer pushes code, triggering the **Build and Test** workflow automatically.
2. The team reviews the workflow results and test logs. The runner completes its job, reports success, and shuts down.
3. When the release is approved, an authorized team member navigates to the StackitGit Actions tab, selects the **Manual Production Deploy** workflow, and clicks "Run Workflow".