├── .env.example ├── Makefile ├── .gitignore ├── .github └── dependabot.yml ├── Dockerfile ├── cloudbuild.yaml ├── go.mod ├── utils.go ├── LICENSE ├── README.md ├── shields.go ├── go.sum ├── main.go └── playground.go /.env.example: -------------------------------------------------------------------------------- 1 | # app environment 2 | APP_ENV=development 3 | 4 | # github token for private repos 5 | GITHUB_TOKEN= 6 | 7 | # sentry dsn 8 | SENTRY_DSN= 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # binary names 2 | BINARY_NAME=github-actions-badge 3 | 4 | # go commands 5 | GOCMD=go 6 | GOBUILD=$(GOCMD) build -v -o $(BINARY_NAME) 7 | 8 | docker-binary: 9 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "-extldflags '-static'" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### General ### 2 | .env 3 | 4 | ### Go ### 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "12:00" 8 | timezone: Etc/UTC 9 | 10 | - package-ecosystem: docker 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | time: "12:00" 15 | timezone: Etc/UTC 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build binary 2 | FROM golang:1.21.4 AS builder 3 | WORKDIR /app 4 | 5 | # populate the module cache based on the go.{mod,sum} files. 6 | COPY go.mod . 7 | COPY go.sum . 8 | RUN go mod download 9 | 10 | # build 11 | COPY . . 12 | RUN make docker-binary 13 | 14 | 15 | # run image 16 | FROM alpine:3.18.4 17 | 18 | # ca-certificates 19 | RUN apk add --no-cache ca-certificates 20 | 21 | # add binary 22 | COPY --from=builder /app/github-actions-badge / 23 | 24 | # ports 25 | EXPOSE 3000 26 | 27 | # run binary 28 | CMD ["/github-actions-badge"] 29 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | # build the container image 3 | - name: 'gcr.io/cloud-builders/docker' 4 | args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_APP_NAME', '.'] 5 | # push the container image to Container Registry 6 | - name: 'gcr.io/cloud-builders/docker' 7 | args: ['push', 'gcr.io/$PROJECT_ID/$_APP_NAME'] 8 | # deploy container image to Cloud Run 9 | - name: 'gcr.io/cloud-builders/gcloud' 10 | args: ['beta', 'run', 'deploy', '$_APP_NAME', '--image', 'gcr.io/$PROJECT_ID/$_APP_NAME', '--region', 'us-central1', '--platform', 'managed', '--quiet'] 11 | images: 12 | - gcr.io/$PROJECT_ID/$_APP_NAME 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.atrox.dev/github-actions-badge 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/getsentry/sentry-go v0.25.0 7 | github.com/go-chi/chi/v5 v5.0.10 8 | github.com/google/go-github/v56 v56.0.0 9 | github.com/pkg/errors v0.9.1 10 | go.atrox.dev/env v1.1.0 11 | golang.org/x/oauth2 v0.15.0 12 | ) 13 | 14 | require ( 15 | github.com/golang/protobuf v1.5.3 // indirect 16 | github.com/google/go-querystring v1.1.0 // indirect 17 | github.com/joho/godotenv v1.5.1 // indirect 18 | golang.org/x/sys v0.15.0 // indirect 19 | golang.org/x/text v0.14.0 // indirect 20 | google.golang.org/appengine v1.6.8 // indirect 21 | google.golang.org/protobuf v1.31.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/getsentry/sentry-go" 8 | ) 9 | 10 | type response struct { 11 | Success bool `json:"success"` 12 | Message string `json:"message"` 13 | } 14 | 15 | func sendJSONResponse(w http.ResponseWriter, r *http.Request, err error) { 16 | var statusCode int 17 | resp := &response{} 18 | if err == nil { 19 | statusCode = http.StatusOK 20 | 21 | resp.Success = true 22 | } else { 23 | statusCode = http.StatusInternalServerError 24 | 25 | resp.Success = false 26 | resp.Message = err.Error() 27 | 28 | sentry.CaptureException(err) 29 | } 30 | 31 | w.Header().Set("Content-Type", "application/json") 32 | w.WriteHeader(statusCode) 33 | 34 | _ = json.NewEncoder(w).Encode(resp) 35 | } 36 | 37 | func sendEndpointResponse(w http.ResponseWriter, r *http.Request, endpoint *Endpoint) { 38 | w.Header().Set("Content-Type", "application/json") 39 | w.WriteHeader(http.StatusOK) 40 | 41 | err := json.NewEncoder(w).Encode(endpoint) 42 | if err != nil { 43 | sendJSONResponse(w, r, err) 44 | return 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Atrox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Badge 2 | 3 | There is a public version of this deployed and free to use at [https://actions-badge.atrox.dev](https://actions-badge.atrox.dev). 4 | 5 | ## Your own 6 | 7 | You can build your own badge in the playground [available here](https://actions-badge.atrox.dev). 8 | 9 | ## Routes 10 | 11 | - `/`: playground 12 | - `///badge`: returns the [endpoint](https://shields.io/endpoint) for shields.io 13 | - `///goto`: redirects to the action 14 | 15 | ## Example 16 | 17 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 18 | 19 | ``` 20 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 21 | ``` 22 | 23 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge&style=flat-square)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 24 | 25 | ``` 26 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge&style=flat-square)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 27 | ``` 28 | 29 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge&label=build&logo=none)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 30 | 31 | ``` 32 | [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fatrox%2Fsync-dotenv%2Fbadge&label=build&logo=none)](https://actions-badge.atrox.dev/atrox/sync-dotenv/goto) 33 | ``` 34 | 35 | For example, you can see this badge in action at [atrox/sync-dotenv](https://github.com/atrox/sync-dotenv). 36 | 37 | ## Contributing 38 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 39 | 40 | - [Report bugs](https://github.com/atrox/github-actions-badge/issues) 41 | - Fix bugs and [submit pull requests](https://github.com/atrox/github-actions-badge/pulls) 42 | - Write, clarify, or fix documentation 43 | - Suggest or add new features 44 | -------------------------------------------------------------------------------- /shields.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Endpoint specifies the shields.io expected endpoint response 4 | // 5 | // Documentation: https://shields.io/endpoint 6 | type Endpoint struct { 7 | SchemaVersion int `json:"schemaVersion,omitempty"` 8 | Label string `json:"label,omitempty"` 9 | Message string `json:"message,omitempty"` 10 | Color string `json:"color,omitempty"` 11 | LabelColor string `json:"labelColor,omitempty"` 12 | IsError bool `json:"isError,omitempty"` 13 | NamedLogo string `json:"namedLogo,omitempty"` 14 | LogoSVG string `json:"logoSvg,omitempty"` 15 | LogoColor string `json:"logoColor,omitempty"` 16 | LogoWidth int `json:"logoWidth,omitempty"` 17 | LogoPosition string `json:"logoPosition,omitempty"` 18 | Style string `json:"style,omitempty"` 19 | CacheSeconds int `json:"cacheSeconds,omitempty"` 20 | } 21 | 22 | // NewEndpoint instantiates a new endpoint instance with default values 23 | func NewEndpoint() *Endpoint { 24 | return &Endpoint{ 25 | SchemaVersion: 1, 26 | CacheSeconds: 300, 27 | 28 | Label: "GitHub Actions", 29 | NamedLogo: "github", 30 | } 31 | } 32 | 33 | func (e *Endpoint) Success() { 34 | e.Color = "success" 35 | e.Message = "success" 36 | } 37 | 38 | func (e *Endpoint) Neutral() { 39 | e.Color = "success" 40 | e.Message = "neutral" 41 | } 42 | 43 | func (e *Endpoint) Pending() { 44 | e.Color = "yellow" 45 | e.Message = "pending" 46 | } 47 | 48 | func (e *Endpoint) Failure() { 49 | e.Color = "critical" 50 | e.Message = "failure" 51 | } 52 | 53 | func (e *Endpoint) Cancelled() { 54 | e.Color = "inactive" 55 | e.Message = "cancelled" 56 | } 57 | 58 | func (e *Endpoint) TimedOut() { 59 | e.Color = "critical" 60 | e.Message = "timed out" 61 | e.IsError = true 62 | } 63 | 64 | func (e *Endpoint) ActionRequired() { 65 | e.Color = "critical" 66 | e.Message = "action required" 67 | e.IsError = true 68 | } 69 | 70 | func (e *Endpoint) ServerError() { 71 | e.Color = "inactive" 72 | e.Message = "server error" 73 | e.IsError = true 74 | } 75 | 76 | func (e *Endpoint) NoRuns() { 77 | e.Color = "inactive" 78 | e.Message = "no runs" 79 | e.IsError = true 80 | } 81 | 82 | func (e *Endpoint) RepositoryNotFound() { 83 | e.Color = "critical" 84 | e.Message = "repository not found" 85 | e.IsError = true 86 | } 87 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= 4 | github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= 5 | github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= 6 | github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 7 | github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 8 | github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 9 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 10 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 11 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 12 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 13 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 14 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 16 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4= 18 | github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0= 19 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 20 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 21 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 22 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 23 | github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= 24 | github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= 25 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 26 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 30 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 31 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 32 | go.atrox.dev/env v1.1.0 h1:Tuge2AM65RCXnE0IslGqnYqwWNRB8Zy8/518Ar4Sth0= 33 | go.atrox.dev/env v1.1.0/go.mod h1:aJhwnP2zWo8XCGUAgOYW/6VZ8J4SisYAKaOndltAiiI= 34 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 35 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 36 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 37 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 39 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 40 | golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= 41 | golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= 42 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 44 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 45 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 50 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 53 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 54 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 55 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 56 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 57 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 58 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 59 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 60 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 61 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 62 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 64 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 65 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 66 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 67 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 68 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 69 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 70 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 71 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/flate" 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/getsentry/sentry-go" 12 | "github.com/go-chi/chi/v5" 13 | "github.com/go-chi/chi/v5/middleware" 14 | "github.com/google/go-github/v56/github" 15 | "github.com/pkg/errors" 16 | "go.atrox.dev/env" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | var defaultGithubClient *github.Client 21 | 22 | func init() { 23 | githubToken := env.Get("GITHUB_TOKEN") 24 | if githubToken == "" { 25 | defaultGithubClient = github.NewClient(nil) 26 | } else { 27 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: githubToken}) 28 | tc := oauth2.NewClient(context.Background(), ts) 29 | defaultGithubClient = github.NewClient(tc) 30 | } 31 | 32 | // capture errors in production 33 | if env.IsProduction() { 34 | // use the sentry sync transport to ensure events getting sent in cloud run environment 35 | sentrySyncTransport := sentry.NewHTTPSyncTransport() 36 | sentrySyncTransport.Timeout = time.Second * 3 37 | 38 | err := sentry.Init(sentry.ClientOptions{ 39 | Dsn: env.Get("SENTRY_DSN"), 40 | Transport: sentrySyncTransport, 41 | }) 42 | if err != nil { 43 | log.Printf("WARN: raven could not be initialized: %s\n", err.Error()) 44 | } 45 | } 46 | } 47 | 48 | func main() { 49 | r := chi.NewRouter() 50 | 51 | r.Use(middleware.RequestID) 52 | r.Use(middleware.RealIP) 53 | r.Use(middleware.Logger) 54 | r.Use(middleware.Recoverer) 55 | r.Use(middleware.GetHead) 56 | r.Use(middleware.RedirectSlashes) 57 | r.Use(middleware.Timeout(5 * time.Second)) 58 | r.Use(middleware.NewCompressor(flate.DefaultCompression).Handler) 59 | 60 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 61 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 62 | w.WriteHeader(http.StatusOK) 63 | 64 | _, err := fmt.Fprint(w, playgroundHTML) 65 | if err != nil { 66 | sendJSONResponse(w, r, err) 67 | } 68 | }) 69 | 70 | r.Route("/{owner}/{repo}", func(r chi.Router) { 71 | r.Use(getCheck) 72 | 73 | r.Get("/badge", badgeRoute) 74 | r.Get("/goto", gotoRoute) 75 | }) 76 | 77 | if err := http.ListenAndServe(fmt.Sprintf(":%s", env.GetDefault("PORT", "3000")), r); err != nil { 78 | log.Fatal(err) 79 | } 80 | } 81 | 82 | func getCheck(next http.Handler) http.Handler { 83 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 | ctx := r.Context() 85 | 86 | owner := chi.URLParamFromCtx(ctx, "owner") 87 | repo := chi.URLParamFromCtx(ctx, "repo") 88 | 89 | ref := r.URL.Query().Get("ref") 90 | if ref == "" { 91 | ref = "master" 92 | } 93 | 94 | var client *github.Client 95 | token := r.URL.Query().Get("token") 96 | if token != "" { 97 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 98 | tc := oauth2.NewClient(ctx, ts) 99 | client = github.NewClient(tc) 100 | } else { 101 | client = defaultGithubClient 102 | } 103 | ctx = context.WithValue(ctx, "client", client) 104 | 105 | checks, _, err := client.Checks.ListCheckSuitesForRef(ctx, owner, repo, ref, &github.ListCheckSuiteOptions{ 106 | AppID: github.Int(15368), 107 | }) 108 | if err != nil { 109 | if githubError, ok := err.(*github.ErrorResponse); ok { 110 | if githubError.Response.StatusCode == http.StatusNotFound { 111 | endpoint := NewEndpoint() 112 | endpoint.RepositoryNotFound() 113 | sendEndpointResponse(w, r, endpoint) 114 | return 115 | } 116 | } 117 | 118 | sendJSONResponse(w, r, err) 119 | return 120 | } 121 | 122 | check := getRelevantCheckSuite(checks.CheckSuites) 123 | if check == nil { 124 | endpoint := NewEndpoint() 125 | endpoint.NoRuns() 126 | sendEndpointResponse(w, r, endpoint) 127 | return 128 | } 129 | 130 | ctx = context.WithValue(ctx, "check", check) 131 | next.ServeHTTP(w, r.WithContext(ctx)) 132 | }) 133 | } 134 | 135 | func badgeRoute(w http.ResponseWriter, r *http.Request) { 136 | ctx := r.Context() 137 | check := ctx.Value("check").(*github.CheckSuite) 138 | endpoint := NewEndpoint() 139 | 140 | status := check.GetStatus() 141 | switch status { 142 | case "queued", "in_progress": 143 | endpoint.Pending() 144 | sendEndpointResponse(w, r, endpoint) 145 | return 146 | case "completed": 147 | // continue 148 | default: 149 | endpoint.ServerError() 150 | sendEndpointResponse(w, r, endpoint) 151 | return 152 | } 153 | 154 | conclusion := check.GetConclusion() 155 | if conclusion == "" { 156 | endpoint.ServerError() 157 | sendEndpointResponse(w, r, endpoint) 158 | return 159 | } 160 | 161 | switch conclusion { 162 | case "success": 163 | endpoint.Success() 164 | case "failure": 165 | endpoint.Failure() 166 | case "neutral": 167 | endpoint.Neutral() 168 | case "cancelled": 169 | endpoint.Cancelled() 170 | case "timed_out": 171 | endpoint.TimedOut() 172 | case "action_required": 173 | endpoint.ActionRequired() 174 | default: 175 | endpoint.ServerError() 176 | } 177 | sendEndpointResponse(w, r, endpoint) 178 | } 179 | 180 | func gotoRoute(w http.ResponseWriter, r *http.Request) { 181 | ctx := r.Context() 182 | check := ctx.Value("check").(*github.CheckSuite) 183 | client := ctx.Value("client").(*github.Client) 184 | 185 | owner := chi.URLParamFromCtx(ctx, "owner") 186 | repo := chi.URLParamFromCtx(ctx, "repo") 187 | 188 | runs, _, err := client.Checks.ListCheckRunsCheckSuite(ctx, owner, repo, check.GetID(), &github.ListCheckRunsOptions{}) 189 | if err != nil { 190 | sendJSONResponse(w, r, err) 191 | return 192 | } 193 | 194 | if len(runs.CheckRuns) <= 0 { 195 | sendJSONResponse(w, r, errors.New("no check runs found")) 196 | return 197 | } 198 | 199 | http.Redirect(w, r, runs.CheckRuns[runs.GetTotal()-1].GetHTMLURL(), http.StatusFound) 200 | } 201 | 202 | // getRelevantCheckSuite returns the most relevant check suite 203 | func getRelevantCheckSuite(checks []*github.CheckSuite) (finalCheck *github.CheckSuite) { 204 | for _, check := range checks { 205 | status := check.GetStatus() 206 | switch status { 207 | case "queued", "in_progress": 208 | return check 209 | case "completed": 210 | // continue 211 | default: 212 | return check 213 | } 214 | 215 | conclusion := check.GetConclusion() 216 | switch conclusion { 217 | case "success": 218 | finalCheck = check 219 | case "neutral": 220 | if finalCheck == nil || finalCheck.GetConclusion() != "success" { 221 | finalCheck = check 222 | } 223 | case "failure", "cancelled", "timed_out", "action_required": 224 | return check 225 | default: 226 | return check 227 | } 228 | } 229 | return 230 | } 231 | -------------------------------------------------------------------------------- /playground.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const playgroundHTML = ` 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | GitHub Actions Badge 12 | 13 | 14 | 15 |
16 |
17 |
18 |

19 | GitHub Actions Badge 20 |

21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 |
44 |
45 |
46 | 47 | 48 |
49 | 50 |
51 |
52 | 61 |
62 |
63 |
64 | 65 |
66 |
67 | 71 |
72 |
73 | 74 |
75 | 76 |
77 | 78 |
79 |
80 | 81 |
82 |
83 | 84 | Build Status 85 | 86 | 87 | 88 |
89 |
90 | 91 |
92 |
93 | 96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 |
104 |
105 | 108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | 118 |
119 |
120 |

121 | GitHub Actions Badge by atrox
122 | The source code is available on GitHub 123 |

124 |
125 |
126 | 127 | 128 | 189 | 190 | 191 | 192 | 193 | ` 194 | --------------------------------------------------------------------------------