diff --git a/README.md b/README.md
index ef6bd69..287adea 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,23 @@ Fetches and creates versioned GitHub releases.
fine-grained access token you create is only
required to have the content permission. For classic access tokens, you need the
repo or public_repo permission.
+ Mutually exclusive with app_id/private_key.
+
+
+
+ app_id (Optional) |
+
+ The ID of the GitHub App to authenticate as. Must be used together with
+ private_key. The resource will automatically discover the App
+ installation for the configured owner/repository.
+ Mutually exclusive with access_token.
+ |
+
+
+ private_key (Optional) |
+
+ The PEM-encoded private key of the GitHub App. Must be used together with
+ app_id. Mutually exclusive with access_token.
|
@@ -148,6 +165,18 @@ Fetches and creates versioned GitHub releases.
access_token: abcdef1234567890
```
+Or using a GitHub App:
+
+``` yaml
+- name: gh-release
+ type: github-release
+ source:
+ owner: concourse
+ repository: concourse
+ app_id: ((github-app-id))
+ private_key: ((github-app-private-key))
+```
+
``` yaml
- get: gh-release
```
diff --git a/github.go b/github.go
index 71a3c36..6009751 100644
--- a/github.go
+++ b/github.go
@@ -11,6 +11,7 @@ import (
"os"
"strings"
+ "github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v66/github"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
@@ -38,29 +39,54 @@ type GitHubClient struct {
client *github.Client
clientV4 *githubv4.Client
- owner string
- repository string
- accessToken string
+ owner string
+ repository string
+ hasAuth bool
+ tokenFunc func(ctx context.Context) (string, error)
}
func NewGitHubClient(source Source) (*GitHubClient, error) {
- httpClient := &http.Client{}
+ if err := validateAuth(source); err != nil {
+ return nil, err
+ }
+
+ var baseTransport http.RoundTripper = http.DefaultTransport
ctx := context.TODO()
if source.Insecure {
- httpClient.Transport = &http.Transport{
+ baseTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
- ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
+ ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: baseTransport})
}
- if source.AccessToken != "" {
+ var httpClient *http.Client
+ var hasAuth bool
+ var tokenFunc func(ctx context.Context) (string, error)
+
+ switch {
+ case source.AccessToken != "":
var err error
httpClient, err = oauthClient(ctx, source)
if err != nil {
return nil, err
}
+ hasAuth = true
+ tokenFunc = func(_ context.Context) (string, error) {
+ return source.AccessToken, nil
+ }
+
+ case source.AppID != 0:
+ var err error
+ httpClient, tokenFunc, err = appInstallationClient(ctx, baseTransport, source)
+ if err != nil {
+ return nil, err
+ }
+ hasAuth = true
+
+ default:
+ httpClient = &http.Client{Transport: baseTransport}
}
client := github.NewClient(httpClient)
@@ -109,16 +135,17 @@ func NewGitHubClient(source Source) (*GitHubClient, error) {
}
return &GitHubClient{
- client: client,
- clientV4: clientV4,
- owner: owner,
- repository: source.Repository,
- accessToken: source.AccessToken,
+ client: client,
+ clientV4: clientV4,
+ owner: owner,
+ repository: source.Repository,
+ hasAuth: hasAuth,
+ tokenFunc: tokenFunc,
}, nil
}
func (g *GitHubClient) ListReleases() ([]*github.RepositoryRelease, error) {
- if g.accessToken != "" {
+ if g.hasAuth {
return g.listReleasesV4()
}
opt := &github.ListOptions{PerPage: 100}
@@ -266,8 +293,12 @@ func (g *GitHubClient) DownloadReleaseAsset(asset github.ReleaseAsset) (io.ReadC
return nil, err
}
req.Header.Set("Accept", "application/octet-stream")
- if g.accessToken != "" && req.URL.Host == g.client.BaseURL.Host {
- req.Header.Set("Authorization", "Bearer "+g.accessToken)
+ if g.hasAuth && req.URL.Host == g.client.BaseURL.Host {
+ token, err := g.tokenFunc(context.TODO())
+ if err != nil {
+ return nil, fmt.Errorf("obtaining auth token for asset download: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
}
httpClient := &http.Client{}
@@ -345,6 +376,81 @@ func (g *GitHubClient) ResolveTagToCommitSHA(tagName string) (string, error) {
return "", fmt.Errorf("could not resolve tag %q to commit: exceeded maximum tag chain depth of %d", tagName, maxDepth)
}
+func validateAuth(source Source) error {
+ hasToken := source.AccessToken != ""
+ hasAppID := source.AppID != 0
+ hasKey := source.PrivateKey != ""
+
+ if hasToken && (hasAppID || hasKey) {
+ return errors.New("cannot specify both access_token and app credentials (app_id/private_key)")
+ }
+ if hasAppID != hasKey {
+ return errors.New("both app_id and private_key must be specified for GitHub App auth")
+ }
+ return nil
+}
+
+func appInstallationClient(ctx context.Context, baseTransport http.RoundTripper, source Source) (*http.Client, func(context.Context) (string, error), error) {
+ privateKey := []byte(source.PrivateKey)
+ apiURL := normalizeURL(source.GitHubAPIURL)
+
+ // Create an app-level transport to discover the installation
+ appsTransport, err := ghinstallation.NewAppsTransport(baseTransport, int64(source.AppID), privateKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating GitHub App transport: %w", err)
+ }
+
+ if apiURL != "" {
+ appsTransport.BaseURL = apiURL
+ }
+
+ // Discover the installation ID for this repository
+ appClient := github.NewClient(&http.Client{Transport: appsTransport})
+ if apiURL != "" {
+ appClient.BaseURL, err = url.Parse(apiURL)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parsing GitHub API URL: %w", err)
+ }
+ }
+
+ owner := source.Owner
+ if source.User != "" {
+ owner = source.User
+ }
+
+ installation, _, err := appClient.Apps.FindRepositoryInstallation(ctx, owner, source.Repository)
+ if err != nil {
+ return nil, nil, fmt.Errorf("finding GitHub App installation for %s/%s: %w", owner, source.Repository, err)
+ }
+
+ // Create installation-level transport for authenticated API access
+ itr, err := ghinstallation.New(baseTransport, int64(source.AppID), installation.GetID(), privateKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating installation transport: %w", err)
+ }
+
+ if apiURL != "" {
+ itr.BaseURL = apiURL
+ }
+
+ httpClient := &http.Client{Transport: itr}
+ tokenFunc := func(ctx context.Context) (string, error) {
+ return itr.Token(ctx)
+ }
+
+ return httpClient, tokenFunc, nil
+}
+
+func normalizeURL(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+ if !strings.HasSuffix(rawURL, "/") {
+ return rawURL + "/"
+ }
+ return rawURL
+}
+
func oauthClient(ctx context.Context, source Source) (*http.Client, error) {
ts := oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: source.AccessToken,
diff --git a/github_test.go b/github_test.go
index 953e2ba..62c71d8 100644
--- a/github_test.go
+++ b/github_test.go
@@ -1,6 +1,11 @@
package resource_test
import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
"fmt"
"io"
"net/http"
@@ -922,4 +927,164 @@ var _ = Describe("GitHub Client", func() {
})
})
})
+
+ Describe("FlexInt64 unmarshaling", func() {
+ It("unmarshals app_id from a JSON number", func() {
+ input := `{"owner":"o","repository":"r","app_id":12345,"private_key":"k"}`
+ var s Source
+ err := json.Unmarshal([]byte(input), &s)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(int64(s.AppID)).To(Equal(int64(12345)))
+ })
+
+ It("unmarshals app_id from a JSON string", func() {
+ input := `{"owner":"o","repository":"r","app_id":"67890","private_key":"k"}`
+ var s Source
+ err := json.Unmarshal([]byte(input), &s)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(int64(s.AppID)).To(Equal(int64(67890)))
+ })
+
+ It("returns an error for a non-numeric string", func() {
+ input := `{"owner":"o","repository":"r","app_id":"not-a-number","private_key":"k"}`
+ var s Source
+ err := json.Unmarshal([]byte(input), &s)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("not a valid integer"))
+ })
+ })
+
+ Describe("Auth validation", func() {
+ It("returns an error when both access_token and app credentials are set", func() {
+ _, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ AccessToken: "some-token",
+ AppID: 12345,
+ PrivateKey: "some-key",
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("cannot specify both access_token and app credentials"))
+ })
+
+ It("returns an error when app_id is set without private_key", func() {
+ _, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ AppID: 12345,
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("both app_id and private_key must be specified"))
+ })
+
+ It("returns an error when private_key is set without app_id", func() {
+ _, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ PrivateKey: "some-key",
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("both app_id and private_key must be specified"))
+ })
+ })
+
+ Describe("GitHub App authentication", func() {
+ var testKeyPEM string
+
+ BeforeEach(func() {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ Expect(err).NotTo(HaveOccurred())
+
+ testKeyPEM = string(pem.EncodeToMemory(&pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: x509.MarshalPKCS1PrivateKey(key),
+ }))
+ })
+
+ Context("when the installation is found", func() {
+ It("creates a client that uses the installation token for GraphQL", func() {
+ // Mock: discover installation for repo
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", "/repos/concourse/concourse/installation"),
+ ghttp.RespondWithJSONEncoded(200, map[string]any{
+ "id": 1234,
+ "app_id": 99,
+ }),
+ ),
+ )
+
+ // Mock: create installation access token
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", "/app/installations/1234/access_tokens"),
+ ghttp.RespondWithJSONEncoded(201, map[string]any{
+ "token": "ghs_testinstalltoken",
+ "expires_at": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
+ }),
+ ),
+ )
+
+ // Mock: the actual GraphQL call
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("POST", "/graphql"),
+ ghttp.VerifyHeaderKV("Authorization", "token ghs_testinstalltoken"),
+ ghttp.RespondWith(200, singlePageRespEnterprise),
+ ),
+ )
+
+ appClient, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ AppID: 99,
+ PrivateKey: testKeyPEM,
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).NotTo(HaveOccurred())
+
+ releases, err := appClient.ListReleases()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(releases).To(HaveLen(1))
+ })
+ })
+
+ Context("when the installation is not found", func() {
+ It("returns an error", func() {
+ server.AppendHandlers(
+ ghttp.CombineHandlers(
+ ghttp.VerifyRequest("GET", "/repos/concourse/concourse/installation"),
+ ghttp.RespondWith(404, `{"message": "Not Found"}`),
+ ),
+ )
+
+ _, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ AppID: 99,
+ PrivateKey: testKeyPEM,
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("finding GitHub App installation for concourse/concourse"))
+ })
+ })
+
+ Context("when the private key is invalid", func() {
+ It("returns an error", func() {
+ _, err := NewGitHubClient(Source{
+ Owner: "concourse",
+ Repository: "concourse",
+ AppID: 99,
+ PrivateKey: "not-a-valid-pem-key",
+ GitHubAPIURL: server.URL() + "/",
+ })
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("creating GitHub App transport"))
+ })
+ })
+ })
})
diff --git a/go.mod b/go.mod
index 763e882..6003b27 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.25.0
require (
github.com/Masterminds/semver v1.5.0
+ github.com/bradleyfalzon/ghinstallation/v2 v2.19.0
github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4
github.com/google/go-github/v66 v66.0.0
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2
@@ -18,7 +19,9 @@ require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/go-github/v88 v88.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/nxadm/tail v1.4.5 // indirect
diff --git a/go.sum b/go.sum
index d9a6fb7..1f7ed60 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI=
+github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA=
github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4 h1:J+ghqo7ZubTzelkjo9hntpTtP/9lUCWH9icEmAW+B+Q=
github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4/go.mod h1:socxpf5+mELPbosI149vWpNlHK6mbfWFxSWOoSndXR8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -21,6 +23,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -36,6 +40,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
+github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M=
+github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
diff --git a/resources.go b/resources.go
index 22b3042..35b1993 100644
--- a/resources.go
+++ b/resources.go
@@ -1,9 +1,36 @@
package resource
import (
+ "encoding/json"
+ "fmt"
+ "strconv"
"time"
)
+// FlexInt64 unmarshals from both JSON numbers and strings.
+// Concourse credential managers often interpolate all values as strings.
+type FlexInt64 int64
+
+func (f *FlexInt64) UnmarshalJSON(data []byte) error {
+ var n int64
+ if err := json.Unmarshal(data, &n); err == nil {
+ *f = FlexInt64(n)
+ return nil
+ }
+
+ var s string
+ if err := json.Unmarshal(data, &s); err != nil {
+ return fmt.Errorf("app_id must be a number or numeric string, got: %s", string(data))
+ }
+
+ n, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return fmt.Errorf("app_id is not a valid integer: %q", s)
+ }
+ *f = FlexInt64(n)
+ return nil
+}
+
type Source struct {
Owner string `json:"owner"`
Repository string `json:"repository"`
@@ -14,7 +41,9 @@ type Source struct {
GitHubAPIURL string `json:"github_api_url"`
GitHubV4APIURL string `json:"github_v4_api_url"`
GitHubUploadsURL string `json:"github_uploads_url"`
- AccessToken string `json:"access_token"`
+ AccessToken string `json:"access_token"`
+ AppID FlexInt64 `json:"app_id"`
+ PrivateKey string `json:"private_key"`
Drafts bool `json:"drafts"`
PreRelease bool `json:"pre_release"`
Release bool `json:"release"`