diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..ea65e4d --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,35 @@ +name: build + +on: + workflow_dispatch: + push: + branches: + - main + paths-ignore: + - '**/*.md' + pull_request: + branches: + - main + paths-ignore: + - '**/*.md' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v3 + + - name: setup dependencies + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: enable docker buildx + uses: docker/setup-buildx-action@master + + - name: lint + run: make lint + + - name: build + run: make dry-run diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1f16750 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,41 @@ +name: release +on: + push: + tags: + - v* +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@v3 + + - name: setup dependencies + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Enable experimental features for the Docker + run: | + echo $'{\n "experimental": true\n}' | sudo tee /etc/docker/daemon.json + mkdir -p ~/.docker + echo $'{\n "experimental": "enabled"\n}' | sudo tee ~/.docker/config.json + sudo service docker restart + + - name: enable docker buildx + uses: docker/setup-buildx-action@master + + - name: login to gh registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: prepare a release + run: make build-release + + - name: publish image + run: make push + env: + VERSION: ${{ github.ref_name }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ceab9bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +*.test + +*.out + +go.work + +.idea/* + +dist/ + +.release-env.env diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f35ce13 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,48 @@ +project_name: workflow-watcher +before: + hooks: + - go mod tidy +builds: + - main: ./ + binary: workflow-watcher + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" +dockers: + - use: buildx + goos: linux + goarch: amd64 + image_templates: + - "ghcr.io/mostafahussein/{{ .ProjectName }}:{{ .Version }}-amd64" + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source=https://github.com/mostafahussein/workflow-watcher" + - "--label=org.opencontainers.image.authors=Mostafa Hussein " + - use: buildx + goos: linux + goarch: arm64 + image_templates: + - "ghcr.io/mostafahussein/{{ .ProjectName }}:{{ .Version }}-arm64" + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.source=https://github.com/mostafahussein/workflow-watcher" + - "--label=org.opencontainers.image.authors=Mostafa Hussein " +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-SNAPSHOT" +source: + rlcp: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2eb7ddc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM gcr.io/distroless/static:nonroot +COPY workflow-watcher /usr/local/bin/workflow-watcher +CMD ["/usr/local/bin/workflow-watcher"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..06c6dd9 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +IMAGE_REPO = ghcr.io/mostafahussein/workflow-watcher +PACKAGE_NAME := github.com/mostafahussein/workflow-watcher +GOLANG_CROSS_VERSION ?= v1.20 +SYSROOT_DIR ?= sysroots +SYSROOT_ARCHIVE ?= sysroots.tar.bz2 + +.PHONY: sysroot-pack +sysroot-pack: + @tar cf - $(SYSROOT_DIR) -P | pv -s $[$(du -sk $(SYSROOT_DIR) | awk '{print $1}') * 1024] | pbzip2 > $(SYSROOT_ARCHIVE) + +.PHONY: sysroot-unpack +sysroot-unpack: + @pv $(SYSROOT_ARCHIVE) | pbzip2 -cd | tar -xf - + +.PHONY: dry-run +dry-run: + @docker run \ + --rm \ + -e CGO_ENABLED=1 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v `pwd`:/go/src/$(PACKAGE_NAME) \ + -v `pwd`/sysroot:/sysroot \ + -w /go/src/$(PACKAGE_NAME) \ + goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ + --clean --skip-validate --skip-publish --snapshot + +.PHONY: lint +lint: + docker run --rm -v $$(pwd):/app -w /app golangci/golangci-lint:v1.52.2 golangci-lint run -v + +.PHONY: build-release +build-release: + docker run \ + --rm \ + -e CGO_ENABLED=1 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v `pwd`:/go/src/$(PACKAGE_NAME) \ + -v `pwd`/sysroot:/sysroot \ + -w /go/src/$(PACKAGE_NAME) \ + goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ + release --clean --skip-publish + +.PHONY: push +push: + @if [ -z "$$VERSION" ]; then \ + echo "VERSION is required"; \ + exit 1; \ + fi + export IMAGE_TAG=$(shell echo $$VERSION | sed -e s/^v//); \ + docker push $(IMAGE_REPO):$$IMAGE_TAG-amd64; \ + docker push $(IMAGE_REPO):$$IMAGE_TAG-arm64; \ + docker manifest create $(IMAGE_REPO):$$IMAGE_TAG \ + --amend $(IMAGE_REPO):$$IMAGE_TAG-amd64 \ + --amend $(IMAGE_REPO):$$IMAGE_TAG-arm64; \ + docker manifest push $(IMAGE_REPO):$$IMAGE_TAG; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..83bcbfe --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Workflow Watcher + +[![ci](https://github.com/mostafahussein/workflow-watcher/actions/workflows/build.yaml/badge.svg)](https://github.com/mostafahussein/workflow-watcher/actions/workflows/build.yaml) + +Pause a GitHub Actions workflow and wait for another workflow to complete before continuing. + +Sometimes, a commit can result in cache invalidation, such as updating application dependencies, and you want to apply this commit to multiple branches. To maintain the ***Build Once, Deploy Anywhere*** principle in such cases, you can either wait until a specific branch is built before promoting your artifact to the next environment, or configure your workflow to check if there is an existing workflow running for the same commit in case you reset the other branches to a specific branch that contains the desired commits. + + +The way this action works is the following: + +1. Workflow comes to the `workflow-watcher` action. +2. `workflow-watcher` will check if there is a workflow already running for the specified commit. +3. If and once the previously detected workflow is completed successfully, the workflow will continue. +4. If the previously detected workflow failed for any reason, then the workflow will exit with a failed status. + + +## Usage + +```yaml +steps: + - uses: mostafahussein/workflow-watcher@v1.0.0 + if: ${{ github.ref != 'refs/heads/develop' }} + with: + secret: ${{ secrets.GH_TOKEN }} + repository-name: ${{ github.repository }} + repository-owner: ${{ github.repository_owner }} + head-sha: ${{ github.sha }} + base-branch: "develop" + polling-interval: 60 + +``` + +- `head-sha` is the commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see "[Events that trigger workflows.](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows)" For example, `ffac537e6cbbf934b08745a378932722df287a53`. +- `base-branch` is the branch that will be used as a source for your final artifact. For example, the testing branch will used the same artifact from the develop branch once the build is done, in this case the `base-branch` value should be `develop` +- `polling-interval` determines how often a poll occurs to check for a updates from Github API, by default it will be **30 seconds**, in case you need more time or having issues with Github rate limiting, you can set your own polling interval. + +## 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. + +For instance, if you want your workflow watcher step to timeout after an hour you could do the following: + +```yaml +steps: + - uses: mostafahussein/workflow-watcher@v1.0.0 + timeout-minutes: 60 + ... +``` + +## Limitations + +* 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 job (including a paused job) will be failed [after 6 hours](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. + +## 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/mostafahussein/workflow-watcher +``` + +Build the image: + +``` +$ VERSION=1.1.1-rc.1 make IMAGE_REPO=ghcr.io/mostafahussein/workflow-watcher-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.1.1-rc.1 make IMAGE_REPO=ghcr.io/mostafahussein/workflow-watcher-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/mostafahussein/workflow-watcher-test:1.1.0-rc.1 +``` + +Then to test out the image, run a workflow specifying your dev branch: + +```yaml +- name: Watch Workflow on Develop branch + uses: your-github-user/workflow-watcher@your-dev-branch + if: ${{ github.ref != 'refs/heads/develop' }} + with: + secret: ${{ secrets.GH_TOKEN }} + repository-name: ${{ github.repository }} + repository-owner: ${{ github.repository_owner }} + head-sha: ${{ github.sha }} + base-branch: "develop" + +``` + +For `uses`, this should point to your repo and dev branch. + +## Credits + +- Author: [Mostafa Hussein](https://github.com/mostafahussein) +- Inspired by: [Manual Workflow Approval](https://github.com/trstringer/manual-approval) \ No newline at end of file diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..e2c3ab3 --- /dev/null +++ b/action.yaml @@ -0,0 +1,28 @@ +name: Workflow Watcher +description: Pause a workflow and wait for another workflow to finish first +branding: + icon: pause + color: yellow +inputs: + repository-name: + description: Repository Name + required: true + repository-owner: + description: Repository Owner + required: true + head-sha: + description: Commit Sha + required: true + base-branch: + description: Base branch + required: true + polling-interval: + description: determines how often a poll occurs to check for a updates from Github API (value in seconds) + required: false + default: 30 + secret: + description: Secret + required: true +runs: + using: docker + image: docker://ghcr.io/mostafahussein/workflow-watcher:1.0.0 \ No newline at end of file diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..cbdb510 --- /dev/null +++ b/constants.go @@ -0,0 +1,20 @@ +package main + +type workflowStatus string + +const ( + envVarRepoName string = "INPUT_REPOSITORY-NAME" + envVarHeadSha string = "INPUT_HEAD-SHA" + envVarBaseBranch string = "INPUT_BASE-BRANCH" + envVarRepoOwner string = "INPUT_REPOSITORY-OWNER" + envVarPollingInterval string = "INPUT_POLLING-INTERVAL" + envVarToken string = "INPUT_SECRET" + + workflowStatusFailed workflowStatus = "failure" + workflowStatusCompleted workflowStatus = "completed" + workflowConclusionSuccess workflowStatus = "success" + workflowConclusionFailed workflowStatus = "failure" + workflowConclusionCancelled workflowStatus = "cancelled" + workflowConclusionSkipped workflowStatus = "skipped" + workflowConclusionTimeOut workflowStatus = "timed_out" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cbcc51e --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/mostafahussein/workflow-watcher + +go 1.19 + +require ( + github.com/google/go-github/v51 v51.0.0 + github.com/tidwall/gjson v1.14.4 + golang.org/x/oauth2 v0.7.0 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.1.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/sys v0.7.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9ae133e --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v51 v51.0.0 h1:KCjsbgPV28VoRftdP+K2mQL16jniUsLAJknsOVKwHyU= +github.com/google/go-github/v51 v51.0.0/go.mod h1:kZj/rn/c1lSUbr/PFWl2hhusPV7a5XNYKcwPrd5L3Us= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..7c907fd --- /dev/null +++ b/helpers.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "os" +) + +func handleInterrupt(ctx context.Context) { + closeComment := "Workflow cancelled." + fmt.Println(closeComment) +} + +func validateInput() error { + missingEnvVars := []string{} + if os.Getenv(envVarRepoName) == "" { + missingEnvVars = append(missingEnvVars, envVarRepoName) + } + + if os.Getenv(envVarHeadSha) == "" { + missingEnvVars = append(missingEnvVars, envVarHeadSha) + } + + if os.Getenv(envVarBaseBranch) == "" { + missingEnvVars = append(missingEnvVars, envVarBaseBranch) + } + + if os.Getenv(envVarRepoOwner) == "" { + missingEnvVars = append(missingEnvVars, envVarRepoOwner) + } + + if os.Getenv(envVarPollingInterval) == "" { + missingEnvVars = append(missingEnvVars, envVarPollingInterval) + } + + if os.Getenv(envVarToken) == "" { + missingEnvVars = append(missingEnvVars, envVarToken) + } + + if len(missingEnvVars) > 0 { + return fmt.Errorf("missing env vars: %v", missingEnvVars) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..766ac90 --- /dev/null +++ b/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "time" + + "github.com/google/go-github/v51/github" + "github.com/tidwall/gjson" + "golang.org/x/oauth2" +) + +func newWorkflowStatusLoopChannel(ctx context.Context, wrkflw *workflowEnvironment, client *github.Client) chan int { + channel := make(chan int) + go func() { + for { + workflowRuns, _, err := client.Actions.ListRepositoryWorkflowRuns(ctx, wrkflw.repoOwner, wrkflw.repo, &github.ListWorkflowRunsOptions{ + HeadSHA: wrkflw.headSha, + }) + if err != nil { + fmt.Printf("error listing workflow for a repository: %v\n", err) + channel <- 1 + close(channel) + } + + if workflowRuns.GetTotalCount() == 0 { + fmt.Printf("No workflows found for the specified commit sha") + channel <- 0 + close(channel) + } + parsedOutput, err := json.Marshal(workflowRuns) + if err != nil { + fmt.Printf("error parsing output to json: %v\n", err) + channel <- 1 + close(channel) + } + headBranchWorkflowExists := gjson.Get(string(parsedOutput), "workflow_runs.#(head_branch==\""+wrkflw.baseBranch+"\")").Exists() + if headBranchWorkflowExists { + headBranchWorkflow := gjson.Get(string(parsedOutput), "workflow_runs.#(head_branch==\""+wrkflw.baseBranch+"\")") + headBranchWorkflowStatus := gjson.Get(headBranchWorkflow.String(), "status").String() + fmt.Printf("Base branch workflow status: %s\n", headBranchWorkflowStatus) + switch headBranchWorkflowStatus { + case string(workflowStatusCompleted): + fmt.Println("Base branch workflow status is completed, verifying base branch workflow conclusion...") + headBranchWorkflowConclusion := gjson.Get(headBranchWorkflow.String(), "conclusion").String() + switch headBranchWorkflowConclusion { + case string(workflowConclusionSuccess): + fmt.Printf("Base branch workflow status is success") + channel <- 0 + close(channel) + case string(workflowConclusionFailed): + fmt.Printf("Base branch workflow status is failed") + channel <- 1 + close(channel) + case string(workflowConclusionSkipped): + fmt.Printf("Base branch workflow status is skipped") + channel <- 1 + close(channel) + case string(workflowConclusionTimeOut): + fmt.Printf("Base branch workflow status is timeout") + channel <- 1 + close(channel) + case string(workflowConclusionCancelled): + fmt.Printf("Base branch workflow status is cancelled") + channel <- 1 + close(channel) + } + case string(workflowStatusFailed): + fmt.Printf("Base branch workflow status is failed") + channel <- 1 + close(channel) + } + + time.Sleep(wrkflw.pollingInterval) + } + } + }() + return channel +} + +func newGithubClient(ctx context.Context) (*github.Client, error) { + token := os.Getenv(envVarToken) + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + 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 github.NewClient(tc), nil +} + +func main() { + if err := validateInput(); err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } + + repo := os.Getenv(envVarRepoName) + repoOwner := os.Getenv(envVarRepoOwner) + + ctx := context.Background() + client, err := newGithubClient(ctx) + if err != nil { + fmt.Printf("error connecting to server: %v\n", err) + os.Exit(1) + } + + headSha := os.Getenv(envVarHeadSha) + baseBranch := os.Getenv(envVarBaseBranch) + pollingInterval := os.Getenv(envVarPollingInterval) + + wrkflw, err := newWorkflowEnvironment(repo, repoOwner, headSha, baseBranch, pollingInterval) + if err != nil { + fmt.Printf("error creating workflow environment: %v\n", err) + os.Exit(1) + } + + killSignalChannel := make(chan os.Signal, 1) + signal.Notify(killSignalChannel, os.Interrupt) + + workflowStatusLoopChannel := newWorkflowStatusLoopChannel(ctx, wrkflw, client) + + select { + case exitCode := <-workflowStatusLoopChannel: + os.Exit(exitCode) + case <-killSignalChannel: + handleInterrupt(ctx) + os.Exit(1) + } +} diff --git a/workflow.go b/workflow.go new file mode 100644 index 0000000..5a741e2 --- /dev/null +++ b/workflow.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type workflowEnvironment struct { + repo string + repoOwner string + headSha string + baseBranch string + pollingInterval time.Duration +} + +func newWorkflowEnvironment(repo string, repoOwner string, headSha string, baseBranch string, pollingIntervalInput string) (*workflowEnvironment, error) { + + pollingInterval, err := strconv.Atoi(pollingIntervalInput) + if err != nil { + fmt.Printf("error converting to int: %v\n", err) + os.Exit(1) + } + duration := time.Duration(pollingInterval) * time.Second + + repoOwnerAndName := strings.Split(repo, "/") + var repoName string + if len(repoOwnerAndName) == 2 { + repoName = repoOwnerAndName[1] + } else { + repoName = repoOwnerAndName[0] + } + + return &workflowEnvironment{ + repo: repoName, + repoOwner: repoOwner, + headSha: headSha, + baseBranch: baseBranch, + pollingInterval: duration, + }, nil +}