shopify_api 16.3.1 → 16.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/close-waiting-for-response-issues.yml +13 -7
- data/CHANGELOG.md +9 -0
- data/Gemfile.lock +1 -1
- data/docs/usage/oauth.md +126 -2
- data/lib/shopify_api/auth/client_assertion.rb +157 -0
- data/lib/shopify_api/auth/client_credentials.rb +57 -8
- data/lib/shopify_api/auth/token_exchange.rb +2 -1
- data/lib/shopify_api/clients/http_client.rb +58 -8
- data/lib/shopify_api/clients/http_request.rb +6 -0
- data/lib/shopify_api/errors/invalid_client_assertion_error.rb +9 -0
- data/lib/shopify_api/version.rb +1 -1
- metadata +4 -5
- data/.github/workflows/gardener-investigate-issue.yml +0 -226
- data/.github/workflows/gardener-notify-event.yml +0 -35
- data/.github/workflows/gardener-notify-slack.yml +0 -75
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cbd548afc86fb40ff24448ff45e03de8cc03f447ccd8e720f547fa68928c0bff
|
|
4
|
+
data.tar.gz: 7fa251cefb79e6425b74c30c0249d6f8e814c8d7c6a4e96e9f8c97c539aae54c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9fe21c59123c41d4256a0560be7d110594019a469105590cdd6ada7aec5b087aff847797f47ce9e1e0c6c571ad8d80cb156ec17435be1146d8d854e9d3ee7b8
|
|
7
|
+
data.tar.gz: c0b10efdc9650f3a8caf9074796b192deb4bec47059db2d76755ecac0ae28e10125b03cab5780256e25d61bd375cd698360d89f3cd4bf7d43e533dd03970f2d3
|
|
@@ -3,18 +3,24 @@ on:
|
|
|
3
3
|
schedule:
|
|
4
4
|
- cron: "30 1 * * *"
|
|
5
5
|
workflow_dispatch:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
actions: write
|
|
9
|
+
issues: write
|
|
10
|
+
|
|
6
11
|
jobs:
|
|
7
12
|
check-need-info:
|
|
8
13
|
runs-on: ubuntu-latest
|
|
9
14
|
steps:
|
|
10
|
-
- name:
|
|
11
|
-
uses: actions
|
|
15
|
+
- name: Close inactive issues
|
|
16
|
+
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
|
12
17
|
with:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
days-before-stale: -1
|
|
19
|
+
days-before-close: -1
|
|
20
|
+
days-before-issue-close: 14
|
|
21
|
+
stale-issue-label: "Waiting for Response"
|
|
22
|
+
remove-issue-stale-when-updated: false
|
|
23
|
+
close-issue-message: |
|
|
18
24
|
We are closing this issue because we did not hear back regarding additional details we needed to resolve this issue. If the issue persists and you are able to provide the missing clarification we need, feel free to create a new issue with the additional information.
|
|
19
25
|
|
|
20
26
|
We appreciate your understanding as we try to manage our number of open issues.
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
|
|
4
4
|
## Unreleased
|
|
5
5
|
|
|
6
|
+
## 16.4.0 (2026-11-05)
|
|
7
|
+
- [#1481](https://github.com/Shopify/shopify-api-ruby/pull/1481) `ShopifyAPI::Auth::TokenExchange.exchange_token` now raises `ShopifyAPI::Errors::InvalidJwtTokenError` when token exchange rejects the session token with `invalid_identity_session`, matching the existing `invalid_subject_token` handling.
|
|
8
|
+
- [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add an optional `client_assertion:` argument to `ShopifyAPI::Auth::ClientCredentials.client_credentials`. When supplied, the token request authenticates with a JWT client assertion and sends no client secret. Omitting it, or passing `nil`, keeps the existing client-secret behavior. See the [OAuth documentation](docs/usage/oauth.md#client-assertion-authentication).
|
|
9
|
+
- [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add `ShopifyAPI::Auth::ClientAssertion.create`, an optional helper that signs a client assertion with an `RS256` (RSA, 2048-bit minimum) or `ES256` (EC P-256) private key your app owns. `client_id` defaults to `ShopifyAPI::Context.api_key`, the only value the helper reads from the context, and it never generates, imports, stores, or rotates keys.
|
|
10
|
+
- [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) Add `ShopifyAPI::Errors::InvalidClientAssertionError`, raised for a blank or non-`String` assertion, for an assertion with surrounding whitespace, and for invalid signer input.
|
|
11
|
+
- [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) For client-assertion token requests only, send HTTParty's inherited Basic and digest authentication as `nil`, so a single-use assertion is never replayed against a digest challenge, and reject a URL that carries userinfo before I/O. The HTTP transport, connection settings, non-credential headers, and global HTTParty defaults are unchanged, and everything else in global HTTParty state stays the caller's responsibility.
|
|
12
|
+
- ⚠️ [Behavior change] [#1475](https://github.com/Shopify/shopify-api-ruby/pull/1475) `ShopifyAPI::Auth::ClientCredentials.client_credentials` no longer follows redirects, with either authentication method, and raises `ShopifyAPI::Errors::HttpResponseError` naming the status and the target instead. A 307 or 308 would otherwise forward the credential-bearing request body to the redirect target. This changes the existing client-secret path, including for apps that enabled redirects through global HTTParty defaults; no request is sent to the redirect target. Other operations keep their redirect policy.
|
|
13
|
+
- Limitation: this applies to the client-credentials token request only. `ShopifyAPI::Auth::Oauth.validate_auth_callback`, both `ShopifyAPI::Auth::TokenExchange` requests (`exchange_token` and `migrate_to_expiring_token`), and `ShopifyAPI::Auth::RefreshToken.refresh_access_token` still POST the client secret under HTTParty's default redirect policy.
|
|
14
|
+
|
|
6
15
|
## 16.3.1 (2026-10-22)
|
|
7
16
|
- [#1472](https://github.com/Shopify/shopify-api-ruby/pull/1472) `ShopifyAPI::Auth.embedded_app_url` now raises `ShopifyAPI::Errors::InvalidShopError` unless the decoded `host` is a trusted Shopify Admin host (see `ShopifyAPI::Utils::ShopValidator`). URLs for supported Admin hosts are unchanged.
|
|
8
17
|
- [#1472](https://github.com/Shopify/shopify-api-ruby/pull/1472) `ShopifyAPI::Auth::Oauth.begin_auth` now validates and normalizes `shop` with `ShopifyAPI::Utils::ShopValidator.sanitize!`, and raises `ShopifyAPI::Errors::InvalidShopError` for untrusted domains.
|
data/Gemfile.lock
CHANGED
data/docs/usage/oauth.md
CHANGED
|
@@ -277,18 +277,29 @@ end
|
|
|
277
277
|
### Client Credentials Grant
|
|
278
278
|
|
|
279
279
|
> [!NOTE]
|
|
280
|
-
>
|
|
280
|
+
> Existing client-secret eligibility rules are unchanged. Assertion authentication also supports eligible
|
|
281
|
+
> public apps with a registered public key and an existing installation on the target shop, when the
|
|
282
|
+
> Shopify token endpoint supports this authentication method.
|
|
281
283
|
|
|
282
284
|
> [!WARNING]
|
|
283
285
|
> [token exchange](#token-exchange) (for embedded apps) or the [authorization code grant](#authorization-code-grant) should be used instead of the client credentials grant, if your app is a browser based web app.
|
|
284
286
|
|
|
285
287
|
#### Perform Client Credentials Grant
|
|
286
288
|
Use [`ShopifyAPI::Auth::ClientCredentials`](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/client_credentials.rb) to
|
|
287
|
-
|
|
289
|
+
obtain an application-only access token using the [app's client ID and client secret](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets),
|
|
290
|
+
or a [signed client assertion](#client-assertion-authentication).
|
|
291
|
+
|
|
292
|
+
Client-credentials requests do not follow HTTP redirects, with either authentication method. A redirect
|
|
293
|
+
raises `ShopifyAPI::Errors::HttpResponseError`; no request is sent to its target. This prevents a 307/308
|
|
294
|
+
from forwarding the credential-bearing POST body. This intentionally changes the previous secret-path
|
|
295
|
+
behavior, including when global HTTParty defaults enable redirects. Explicit routing and connection
|
|
296
|
+
settings remain supported, and other operations keep their redirect policy.
|
|
297
|
+
|
|
288
298
|
#### Input
|
|
289
299
|
| Parameter | Type | Required? | Default Value | Notes |
|
|
290
300
|
| -------------- | ---------------------- | :-------: | :-----------: | ----------------------------------------------------------------------------------------------------------- |
|
|
291
301
|
| `shop` | `String` | Yes | - | A Shopify domain name in the form `{exampleshop}.myshopify.com`. |
|
|
302
|
+
| `client_assertion` | `String` | No | `nil` | A signed JWT client assertion. When omitted or `nil`, the grant authenticates with the app's client secret as before. Leading or trailing whitespace is rejected rather than trimmed. See [Client Assertion Authentication](#client-assertion-authentication). |
|
|
292
303
|
|
|
293
304
|
#### Output
|
|
294
305
|
This method returns the new `ShopifyAPI::Auth::Session` object from the client credentials grant, your app should store this `Session` object to be used later [when making authenticated API calls](#using-oauth-session-to-make-authenticated-api-calls).
|
|
@@ -307,6 +318,119 @@ end
|
|
|
307
318
|
|
|
308
319
|
```
|
|
309
320
|
|
|
321
|
+
### Client Assertion Authentication
|
|
322
|
+
|
|
323
|
+
Instead of the app's client secret, the client credentials grant can authenticate with a short-lived JWT
|
|
324
|
+
client assertion signed by a private key your app owns. Pass the assertion as `client_assertion:`; the
|
|
325
|
+
request then sends only `client_id`, `grant_type`, `client_assertion_type` and `client_assertion`, and no
|
|
326
|
+
client secret.
|
|
327
|
+
|
|
328
|
+
To generate a key pair, register its public key with your app and see the claims Shopify expects, read
|
|
329
|
+
[Get application-only access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens).
|
|
330
|
+
|
|
331
|
+
> [!IMPORTANT]
|
|
332
|
+
> An assertion is **single use**. Every token request — including every retry after a failure — needs a
|
|
333
|
+
> newly created assertion, because a second use of the same assertion is refused. This library makes one
|
|
334
|
+
> HTTP attempt per call and does not follow redirects on the assertion request, so a consumed assertion is
|
|
335
|
+
> never replayed on your behalf.
|
|
336
|
+
|
|
337
|
+
The assertion request uses the same HTTP transport as other requests. For this call only, it sends
|
|
338
|
+
`basic_auth` and `digest_auth` as `nil`, so inherited Basic or digest credentials are never attached and
|
|
339
|
+
HTTParty cannot replay the single-use assertion against a digest challenge. Global HTTParty defaults are
|
|
340
|
+
not modified, connection settings such as proxy, CA, and timeout configuration remain in effect, and so do
|
|
341
|
+
non-credential headers. Requests without an assertion keep their authentication and connection settings,
|
|
342
|
+
but follow the same no-redirect policy.
|
|
343
|
+
|
|
344
|
+
The request also fails before I/O with `ShopifyAPI::Errors::InvalidHttpRequestError` if
|
|
345
|
+
`ShopifyAPI::Context.api_host` contains URL userinfo (`user:password@host`). HTTParty rebuilds Basic
|
|
346
|
+
credentials from the URL after the `nil` override, so those credentials would otherwise be sent.
|
|
347
|
+
|
|
348
|
+
Everything else in global HTTParty state is yours to control, and this library neither inspects nor changes
|
|
349
|
+
it. Two cases are worth calling out: a `logger` with `log_format: :curl` writes `request.raw_body`, and
|
|
350
|
+
therefore the assertion, into your application log; and default headers such as `Cookie` or
|
|
351
|
+
`Proxy-Authorization`, along with `HTTParty.cookies`, are attached to this request like any other. Custom
|
|
352
|
+
connection adapters and diagnostic logging must not add OAuth credentials, replay the assertion, or log it.
|
|
353
|
+
|
|
354
|
+
Your app owns the key pair. This library never generates, imports, stores, or rotates keys, and never reads
|
|
355
|
+
a key from `ShopifyAPI::Context`. Register the matching public key with Shopify and pass its key ID as
|
|
356
|
+
`kid`. Omitting `client_assertion` keeps the existing client-secret behavior, and a blank or non-`String`
|
|
357
|
+
value raises `ShopifyAPI::Errors::InvalidClientAssertionError` rather than silently falling back to the
|
|
358
|
+
secret.
|
|
359
|
+
|
|
360
|
+
> [!IMPORTANT]
|
|
361
|
+
> **You must supply the target shop's permanent domain.** The helper normalizes case and surrounding
|
|
362
|
+
> whitespace only; it does not look up the permanent domain or verify the shop's identity. Every domain that
|
|
363
|
+
> is not a `myshopify.com` domain is rejected, including custom storefront domains and the development
|
|
364
|
+
> domains `ShopifyAPI::Utils::ShopValidator` trusts. A valid
|
|
365
|
+
> `myshopify.com` domain is used as supplied, so you must select the correct shop and use the same canonical
|
|
366
|
+
> domain when exchanging the assertion.
|
|
367
|
+
|
|
368
|
+
#### Creating an assertion
|
|
369
|
+
|
|
370
|
+
[`ShopifyAPI::Auth::ClientAssertion.create`](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth/client_assertion.rb)
|
|
371
|
+
is an optional helper that signs an assertion with a key your app already holds in memory. It modifies
|
|
372
|
+
nothing, needs no client secret, and the only value it reads from `ShopifyAPI::Context` is `api_key`, as the
|
|
373
|
+
default for `client_id`.
|
|
374
|
+
|
|
375
|
+
| Parameter | Type | Required? | Default Value | Notes |
|
|
376
|
+
| --------- | ---- | :-------: | :-----------: | ----- |
|
|
377
|
+
| `shop` | `String` | Yes | - | The shop's permanent `{shop}.myshopify.com` domain, which becomes the assertion's `aud`. Only case and surrounding whitespace are normalized; a bare shop handle, a full URL, an `admin.shopify.com/store/{shop}` URL, and anything that is not a `myshopify.com` domain are all rejected. Not used when `audience` is supplied. |
|
|
378
|
+
| `client_id` | `String` | No | `ShopifyAPI::Context.api_key` | Your app's client ID. Becomes both `iss` and `sub`, which Shopify requires to match the `client_id` the token request sends — so the default is normally the right value. |
|
|
379
|
+
| `kid` | `String` | Yes | - | The key ID of the registered public key. Surrounding whitespace is trimmed, and a `kid` longer than 255 characters is rejected, since Shopify cannot store one. |
|
|
380
|
+
| `private_key` | `OpenSSL::PKey::RSA` or `OpenSSL::PKey::EC` | Yes | - | A private key object. RSA keys must be at least 2048 bits (`RS256`); EC keys must use the P-256 curve (`ES256`). Anything else is rejected. |
|
|
381
|
+
| `lifetime_seconds` | `Integer` | No | `60` | Must be between 1 and 300. Out-of-range values are rejected, not clamped. |
|
|
382
|
+
| `audience` | `String` | No | `nil` | Sets the `aud` claim directly and skips all `shop` validation. For development environments only, where the shop's permanent domain is not a `myshopify.com` domain; you are responsible for the value. |
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
# `private_key` is an OpenSSL::PKey::RSA or OpenSSL::PKey::EC private key your app loaded itself.
|
|
386
|
+
def authenticate_with_assertion(shop, private_key)
|
|
387
|
+
assertion = ShopifyAPI::Auth::ClientAssertion.create(
|
|
388
|
+
shop: shop,
|
|
389
|
+
kid: "my-registered-key-id",
|
|
390
|
+
private_key: private_key,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
session = ShopifyAPI::Auth::ClientCredentials.client_credentials(
|
|
394
|
+
shop: shop,
|
|
395
|
+
client_assertion: assertion,
|
|
396
|
+
)
|
|
397
|
+
SessionRepository.store_session(session)
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The algorithm is derived from the key, and the claims (`iss`, `sub`, `aud`, `iat`, `exp`, and a fresh `jti`
|
|
402
|
+
per call) are built by the library. Supplying your own claims, headers, or algorithm is not supported. If
|
|
403
|
+
you sign elsewhere — with a KMS or HSM, for example — skip this helper and pass the finished assertion
|
|
404
|
+
string straight to `client_credentials`.
|
|
405
|
+
|
|
406
|
+
Keep the signing host's clock synchronized. Shopify checks `iat` and `exp` against its own clock, so a host
|
|
407
|
+
whose clock is off can sign assertions that are rejected. If the clock may drift, raise `lifetime_seconds`
|
|
408
|
+
toward 300.
|
|
409
|
+
|
|
410
|
+
Pass the full `{shop}.myshopify.com` domain to both calls, as the example above does. The signer and
|
|
411
|
+
`client_credentials` accept the same canonical domain, so one value works for both.
|
|
412
|
+
|
|
413
|
+
A development store whose permanent domain is not a `myshopify.com` domain is rejected by the signer. Pass
|
|
414
|
+
`audience:` in that case, built as
|
|
415
|
+
`https://{permanent domain}/admin/oauth/access_token`, and keep it out of production code paths.
|
|
416
|
+
|
|
417
|
+
Your app still needs its real client secret configured: `ShopifyAPI::Context.setup` requires
|
|
418
|
+
`api_secret_key`, and other parts of this library use it, such as webhook HMAC and session-token
|
|
419
|
+
verification. On the assertion path it is never read, never sent, and never used.
|
|
420
|
+
|
|
421
|
+
#### Troubleshooting
|
|
422
|
+
|
|
423
|
+
A `401 invalid_client` response means the assertion was rejected. The response doesn't say why, so check
|
|
424
|
+
that:
|
|
425
|
+
|
|
426
|
+
- the `kid` names an active key registered for this app
|
|
427
|
+
- the assertion was signed with that key's private key
|
|
428
|
+
- `aud` is `https://{the shop's permanent domain}/admin/oauth/access_token`
|
|
429
|
+
- `iss` and `sub` are the `client_id` the request sends
|
|
430
|
+
- the assertion is new: each one can only be used once
|
|
431
|
+
|
|
432
|
+
A `5xx` response is temporary. Sign a new assertion before you retry.
|
|
433
|
+
|
|
310
434
|
## Expiring Offline Access Tokens
|
|
311
435
|
|
|
312
436
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ShopifyAPI
|
|
5
|
+
module Auth
|
|
6
|
+
module ClientAssertion
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
DEFAULT_LIFETIME_SECONDS = 60
|
|
10
|
+
# Mirrors Shopify's own bound: an assertion whose `exp - iat` is more than 5 minutes is rejected, and
|
|
11
|
+
# exactly 300 seconds is accepted. Check this value against
|
|
12
|
+
# https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens
|
|
13
|
+
# before changing it.
|
|
14
|
+
MAX_LIFETIME_SECONDS = 300
|
|
15
|
+
RSA_ALGORITHM = "RS256"
|
|
16
|
+
EC_ALGORITHM = "ES256"
|
|
17
|
+
MIN_RSA_KEY_BITS = 2048
|
|
18
|
+
# Shopify accepts a `kid` of at most 255 characters, so a longer one can never match.
|
|
19
|
+
MAX_KID_LENGTH = 255
|
|
20
|
+
SUPPORTED_EC_CURVES = T.let(["prime256v1", "secp256r1"].freeze, T::Array[String])
|
|
21
|
+
PERMANENT_SHOP_DOMAIN = /\A[a-z0-9][a-z0-9_-]*\.myshopify\.com\z/
|
|
22
|
+
MYSHOPIFY_DOMAIN = "myshopify.com"
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
sig do
|
|
28
|
+
params(
|
|
29
|
+
shop: String,
|
|
30
|
+
kid: String,
|
|
31
|
+
private_key: T.any(OpenSSL::PKey::RSA, OpenSSL::PKey::EC),
|
|
32
|
+
client_id: T.nilable(String),
|
|
33
|
+
lifetime_seconds: Integer,
|
|
34
|
+
audience: T.nilable(String),
|
|
35
|
+
).returns(String)
|
|
36
|
+
end
|
|
37
|
+
def create(
|
|
38
|
+
shop:,
|
|
39
|
+
kid:,
|
|
40
|
+
private_key:,
|
|
41
|
+
client_id: nil,
|
|
42
|
+
lifetime_seconds: DEFAULT_LIFETIME_SECONDS,
|
|
43
|
+
audience: nil
|
|
44
|
+
)
|
|
45
|
+
audience = audience.nil? ? audience_for(shop) : validated_audience(audience)
|
|
46
|
+
# The only value read from `ShopifyAPI::Context`, and only when the caller omits it. The server
|
|
47
|
+
# requires `iss` and `sub` to equal the `client_id` the request posts, which is `Context.api_key`.
|
|
48
|
+
client_id = Context.api_key if client_id.nil?
|
|
49
|
+
client_id = validated_value(client_id, "client_id")
|
|
50
|
+
kid = validated_kid(kid)
|
|
51
|
+
validate_lifetime!(lifetime_seconds)
|
|
52
|
+
signing_key, algorithm = validated_key_with_algorithm(private_key)
|
|
53
|
+
|
|
54
|
+
issued_at = Time.now.to_i
|
|
55
|
+
claims = {
|
|
56
|
+
"iss" => client_id,
|
|
57
|
+
"sub" => client_id,
|
|
58
|
+
"aud" => audience,
|
|
59
|
+
"iat" => issued_at,
|
|
60
|
+
"exp" => issued_at + lifetime_seconds,
|
|
61
|
+
"jti" => SecureRandom.uuid,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
JWT.encode(claims, signing_key, algorithm, { "kid" => kid, "typ" => "JWT" })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Only the shop's canonical permanent domain is accepted, so that the signer and
|
|
70
|
+
# `ClientCredentials.client_credentials` agree on what a shop is. Case is normalized with ASCII
|
|
71
|
+
# folding only: Unicode folding maps characters such as U+212A KELVIN SIGN onto ASCII letters, which
|
|
72
|
+
# would turn one shop's name into another real shop's audience.
|
|
73
|
+
sig { params(shop: String).returns(String) }
|
|
74
|
+
def audience_for(shop)
|
|
75
|
+
host = shop.strip.tr("A-Z", "a-z")
|
|
76
|
+
unless PERMANENT_SHOP_DOMAIN.match?(host)
|
|
77
|
+
raise Errors::InvalidClientAssertionError,
|
|
78
|
+
"shop must be a permanent #{MYSHOPIFY_DOMAIN} domain in the form " \
|
|
79
|
+
"{handle}.#{MYSHOPIFY_DOMAIN}, got: #{shop.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ClientCredentials.token_endpoint_url(host)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(audience: String).returns(String) }
|
|
86
|
+
def validated_audience(audience)
|
|
87
|
+
validated_value(audience, "audience")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Trimmed rather than rejected: a value read from a file or pasted from a terminal often carries a
|
|
91
|
+
# newline, and the server matches `iss` and `kid` exactly, so an untrimmed value can only ever fail.
|
|
92
|
+
sig { params(value: String, name: String).returns(String) }
|
|
93
|
+
def validated_value(value, name)
|
|
94
|
+
trimmed = value.strip
|
|
95
|
+
raise Errors::InvalidClientAssertionError, "#{name} must be a non-empty String" if trimmed.empty?
|
|
96
|
+
|
|
97
|
+
trimmed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { params(kid: String).returns(String) }
|
|
101
|
+
def validated_kid(kid)
|
|
102
|
+
trimmed = validated_value(kid, "kid")
|
|
103
|
+
if trimmed.length > MAX_KID_LENGTH
|
|
104
|
+
raise Errors::InvalidClientAssertionError,
|
|
105
|
+
"kid must be at most #{MAX_KID_LENGTH} characters, got: #{trimmed.length}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
trimmed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { params(lifetime_seconds: Integer).void }
|
|
112
|
+
def validate_lifetime!(lifetime_seconds)
|
|
113
|
+
return if lifetime_seconds.positive? && lifetime_seconds <= MAX_LIFETIME_SECONDS
|
|
114
|
+
|
|
115
|
+
raise Errors::InvalidClientAssertionError,
|
|
116
|
+
"lifetime_seconds must be between 1 and #{MAX_LIFETIME_SECONDS}, got: #{lifetime_seconds}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Takes `Object` so that the checks below still run for an app that has turned sorbet-runtime's
|
|
120
|
+
# call validation off.
|
|
121
|
+
sig do
|
|
122
|
+
params(private_key: Object).returns([T.any(OpenSSL::PKey::RSA, OpenSSL::PKey::EC), String])
|
|
123
|
+
end
|
|
124
|
+
def validated_key_with_algorithm(private_key)
|
|
125
|
+
case private_key
|
|
126
|
+
when OpenSSL::PKey::RSA
|
|
127
|
+
unless private_key.private?
|
|
128
|
+
raise Errors::InvalidClientAssertionError, "private_key must be an RSA private key, not a public key"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if private_key.n.num_bits < MIN_RSA_KEY_BITS
|
|
132
|
+
raise Errors::InvalidClientAssertionError,
|
|
133
|
+
"RSA private_key must be at least #{MIN_RSA_KEY_BITS} bits, got: #{private_key.n.num_bits}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
[private_key, RSA_ALGORITHM]
|
|
137
|
+
when OpenSSL::PKey::EC
|
|
138
|
+
unless private_key.private?
|
|
139
|
+
raise Errors::InvalidClientAssertionError, "private_key must be an EC private key, not a public key"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unless SUPPORTED_EC_CURVES.include?(private_key.group.curve_name)
|
|
143
|
+
raise Errors::InvalidClientAssertionError,
|
|
144
|
+
"EC private_key must use the P-256 curve, got: #{private_key.group.curve_name}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
[private_key, EC_ALGORITHM]
|
|
148
|
+
else
|
|
149
|
+
raise Errors::InvalidClientAssertionError,
|
|
150
|
+
"private_key must be an OpenSSL::PKey::RSA or OpenSSL::PKey::EC private key, " \
|
|
151
|
+
"got: #{private_key.class}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -7,16 +7,31 @@ module ShopifyAPI
|
|
|
7
7
|
extend T::Sig
|
|
8
8
|
|
|
9
9
|
CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"
|
|
10
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
11
|
+
TOKEN_ENDPOINT_BASE_PATH = "/admin/oauth"
|
|
12
|
+
TOKEN_ENDPOINT_PATH = "access_token"
|
|
10
13
|
|
|
11
14
|
class << self
|
|
12
15
|
extend T::Sig
|
|
13
16
|
|
|
17
|
+
# The URL this grant posts to. `ClientAssertion.create` derives the assertion's `aud` claim from
|
|
18
|
+
# here rather than spelling the URL out again: Shopify rejects an assertion whose `aud` is not the
|
|
19
|
+
# endpoint the request arrives at, and reports it as an indistinguishable `invalid_client`.
|
|
20
|
+
sig { params(shop: String).returns(String) }
|
|
21
|
+
def token_endpoint_url(shop)
|
|
22
|
+
"https://#{shop}#{TOKEN_ENDPOINT_BASE_PATH}/#{TOKEN_ENDPOINT_PATH}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `client_assertion` is typed `T.untyped` so that a wrong type reaches the validation below:
|
|
26
|
+
# sorbet-runtime's TypeError message embeds the offending argument, and a JWT is short enough that
|
|
27
|
+
# the truncated message shows a usable part of a live credential.
|
|
14
28
|
sig do
|
|
15
29
|
params(
|
|
16
30
|
shop: String,
|
|
31
|
+
client_assertion: T.untyped,
|
|
17
32
|
).returns(ShopifyAPI::Auth::Session)
|
|
18
33
|
end
|
|
19
|
-
def client_credentials(shop:)
|
|
34
|
+
def client_credentials(shop:, client_assertion: nil)
|
|
20
35
|
unless ShopifyAPI::Context.setup?
|
|
21
36
|
raise ShopifyAPI::Errors::ContextNotSetupError,
|
|
22
37
|
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
|
|
@@ -24,20 +39,32 @@ module ShopifyAPI
|
|
|
24
39
|
|
|
25
40
|
validated_shop = Utils::ShopValidator.sanitize!(shop)
|
|
26
41
|
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
|
|
27
|
-
body =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
body = if client_assertion.nil?
|
|
43
|
+
{
|
|
44
|
+
client_id: ShopifyAPI::Context.api_key,
|
|
45
|
+
client_secret: ShopifyAPI::Context.api_secret_key,
|
|
46
|
+
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
{
|
|
50
|
+
client_id: ShopifyAPI::Context.api_key,
|
|
51
|
+
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
|
|
52
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
53
|
+
client_assertion: validated_assertion(client_assertion),
|
|
54
|
+
}
|
|
55
|
+
end
|
|
32
56
|
|
|
33
|
-
client = Clients::HttpClient.new(session: shop_session, base_path:
|
|
57
|
+
client = Clients::HttpClient.new(session: shop_session, base_path: TOKEN_ENDPOINT_BASE_PATH)
|
|
34
58
|
response =
|
|
35
59
|
client.request(
|
|
36
60
|
Clients::HttpRequest.new(
|
|
37
61
|
http_method: :post,
|
|
38
|
-
path:
|
|
62
|
+
path: TOKEN_ENDPOINT_PATH,
|
|
39
63
|
body: body,
|
|
40
64
|
body_type: "application/json",
|
|
65
|
+
tries: 1,
|
|
66
|
+
follow_redirects: false,
|
|
67
|
+
suppress_inherited_http_auth: !client_assertion.nil?,
|
|
41
68
|
),
|
|
42
69
|
)
|
|
43
70
|
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h
|
|
@@ -47,6 +74,28 @@ module ShopifyAPI
|
|
|
47
74
|
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
|
|
48
75
|
)
|
|
49
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
sig { params(client_assertion: Object).returns(String) }
|
|
81
|
+
def validated_assertion(client_assertion)
|
|
82
|
+
unless client_assertion.is_a?(String)
|
|
83
|
+
raise ShopifyAPI::Errors::InvalidClientAssertionError,
|
|
84
|
+
"client_assertion must be a String, got: #{client_assertion.class}"
|
|
85
|
+
end
|
|
86
|
+
if client_assertion.strip.empty?
|
|
87
|
+
raise ShopifyAPI::Errors::InvalidClientAssertionError, "client_assertion must not be blank"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Rejected rather than trimmed: a JWT that arrived with whitespace around it means the caller's
|
|
91
|
+
# plumbing is wrong, and the server compares the assertion byte for byte.
|
|
92
|
+
unless client_assertion == client_assertion.strip
|
|
93
|
+
raise ShopifyAPI::Errors::InvalidClientAssertionError,
|
|
94
|
+
"client_assertion must not have leading or trailing whitespace"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
client_assertion
|
|
98
|
+
end
|
|
50
99
|
end
|
|
51
100
|
end
|
|
52
101
|
end
|
|
@@ -73,7 +73,8 @@ module ShopifyAPI
|
|
|
73
73
|
),
|
|
74
74
|
)
|
|
75
75
|
rescue ShopifyAPI::Errors::HttpResponseError => error
|
|
76
|
-
if error.code == 400 &&
|
|
76
|
+
if error.code == 400 &&
|
|
77
|
+
["invalid_subject_token", "invalid_identity_session"].include?(error.response.body["error"])
|
|
77
78
|
raise ShopifyAPI::Errors::InvalidJwtTokenError, "Session token was rejected by token exchange"
|
|
78
79
|
end
|
|
79
80
|
|
|
@@ -32,11 +32,16 @@ module ShopifyAPI
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
sig
|
|
35
|
+
sig do
|
|
36
|
+
params(
|
|
37
|
+
request: HttpRequest,
|
|
38
|
+
response_as_struct: T::Boolean,
|
|
39
|
+
).returns(HttpResponse)
|
|
40
|
+
end
|
|
36
41
|
def request(request, response_as_struct: false)
|
|
37
42
|
request.verify
|
|
38
43
|
|
|
39
|
-
headers = @headers
|
|
44
|
+
headers = @headers.dup
|
|
40
45
|
headers["Content-Type"] = T.must(request.body_type) if request.body_type
|
|
41
46
|
headers = headers.merge(T.must(request.extra_headers)) if request.extra_headers
|
|
42
47
|
|
|
@@ -48,14 +53,30 @@ module ShopifyAPI
|
|
|
48
53
|
response = HttpResponse.new(code: 0, headers: {}, body: "")
|
|
49
54
|
while tries < request.tries
|
|
50
55
|
tries += 1
|
|
51
|
-
|
|
52
|
-
request.http_method,
|
|
53
|
-
parsed_uri.to_s,
|
|
56
|
+
request_options = {
|
|
54
57
|
headers: headers,
|
|
55
58
|
query: request.query,
|
|
56
59
|
body: request.body.class == Hash ? T.unsafe(request.body).to_json : request.body,
|
|
60
|
+
}
|
|
61
|
+
request_options[:follow_redirects] = request.follow_redirects unless request.follow_redirects.nil?
|
|
62
|
+
|
|
63
|
+
if request.suppress_inherited_http_auth
|
|
64
|
+
verify_no_url_credentials!(parsed_uri)
|
|
65
|
+
request_options.merge!(basic_auth: nil, digest_auth: nil)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
res = T.cast(HTTParty.send(
|
|
69
|
+
request.http_method,
|
|
70
|
+
parsed_uri.to_s,
|
|
71
|
+
**request_options,
|
|
57
72
|
), HTTParty::Response)
|
|
58
73
|
|
|
74
|
+
if request.follow_redirects == false && res.code.to_i.between?(300, 399)
|
|
75
|
+
blocked_response = HttpResponse.new(code: res.code.to_i, headers: res.headers.to_h, body: res.body.to_s)
|
|
76
|
+
raise ShopifyAPI::Errors::HttpResponseError.new(response: blocked_response),
|
|
77
|
+
blocked_redirect_message(res)
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
begin
|
|
60
81
|
body = res.body.nil? || res.body.empty? ? {} : JSON.parse(res.body)
|
|
61
82
|
rescue JSON::ParserError
|
|
@@ -111,9 +132,14 @@ module ShopifyAPI
|
|
|
111
132
|
sig { params(response: HttpResponse).returns(String) }
|
|
112
133
|
def serialized_error(response)
|
|
113
134
|
body = {}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
135
|
+
# Only a parsed JSON object has error members. On an unparsed body `response.body["errors"]` is
|
|
136
|
+
# `String#[]`, so an HTML page containing the word "errors" would report `{"errors":"errors"}`.
|
|
137
|
+
if response.body.is_a?(Hash)
|
|
138
|
+
error_body = T.cast(response.body, T::Hash[String, T.untyped])
|
|
139
|
+
body["errors"] = error_body["errors"] if error_body["errors"]
|
|
140
|
+
body["error"] = error_body["error"] if error_body["error"]
|
|
141
|
+
body["error_description"] = error_body["error_description"] if error_body["error_description"]
|
|
142
|
+
end
|
|
117
143
|
|
|
118
144
|
if response.headers["x-request-id"]
|
|
119
145
|
id = T.must(response.headers["x-request-id"])[0]
|
|
@@ -124,6 +150,30 @@ module ShopifyAPI
|
|
|
124
150
|
|
|
125
151
|
private
|
|
126
152
|
|
|
153
|
+
# A redirect on a credential-bearing request is the one error here a developer cannot diagnose from
|
|
154
|
+
# the status alone, so it says what happened, what was not sent, and what to check.
|
|
155
|
+
sig { params(res: HTTParty::Response).returns(String) }
|
|
156
|
+
def blocked_redirect_message(res)
|
|
157
|
+
location = res.headers["location"]
|
|
158
|
+
target = location.nil? || location.empty? ? "an unnamed location" : location
|
|
159
|
+
|
|
160
|
+
"Refused to follow the #{res.code} redirect to #{target}, because this request was sent with " \
|
|
161
|
+
"redirects disabled: following it would forward the request body, and any credentials in it, to " \
|
|
162
|
+
"the redirect target. Nothing was sent there. Check any proxy or ShopifyAPI::Context.api_host " \
|
|
163
|
+
"configuration in front of the endpoint, and retry with a newly created client assertion if you " \
|
|
164
|
+
"are using one."
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# HTTParty's `set_basic_auth_from_uri` rebuilds `basic_auth` from the URL after an explicit `nil`
|
|
168
|
+
# override, so suppressing inherited authentication is not enough on its own.
|
|
169
|
+
sig { params(parsed_uri: URI::Generic).void }
|
|
170
|
+
def verify_no_url_credentials!(parsed_uri)
|
|
171
|
+
return unless parsed_uri.userinfo
|
|
172
|
+
|
|
173
|
+
raise ShopifyAPI::Errors::InvalidHttpRequestError,
|
|
174
|
+
"Cannot send a request with HTTP authentication disabled to a URL containing credentials."
|
|
175
|
+
end
|
|
176
|
+
|
|
127
177
|
sig do
|
|
128
178
|
params(
|
|
129
179
|
headers: T::Hash[T.any(Symbol, String), T.untyped],
|
|
@@ -13,6 +13,12 @@ module ShopifyAPI
|
|
|
13
13
|
prop :query, T.nilable(T::Hash[T.any(Symbol, String), T.untyped])
|
|
14
14
|
prop :extra_headers, T.nilable(T::Hash[T.any(Symbol, String), T.untyped])
|
|
15
15
|
prop :tries, Integer, default: 1
|
|
16
|
+
# Unset leaves redirect handling to HTTParty, including any policy the app set in default_options.
|
|
17
|
+
prop :follow_redirects, T.nilable(T::Boolean)
|
|
18
|
+
# Sends this one request with HTTParty's inherited Basic and digest authentication turned off. Digest
|
|
19
|
+
# auth matters most: HTTParty replays the whole request on a 401 challenge, which would replay a
|
|
20
|
+
# single-use credential such as a client assertion.
|
|
21
|
+
prop :suppress_inherited_http_auth, T::Boolean, default: false
|
|
16
22
|
|
|
17
23
|
sig { void }
|
|
18
24
|
def verify
|
data/lib/shopify_api/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shopify_api
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 16.
|
|
4
|
+
version: 16.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shopify
|
|
@@ -203,9 +203,6 @@ files:
|
|
|
203
203
|
- ".github/workflows/build.yml"
|
|
204
204
|
- ".github/workflows/cla.yml"
|
|
205
205
|
- ".github/workflows/close-waiting-for-response-issues.yml"
|
|
206
|
-
- ".github/workflows/gardener-investigate-issue.yml"
|
|
207
|
-
- ".github/workflows/gardener-notify-event.yml"
|
|
208
|
-
- ".github/workflows/gardener-notify-slack.yml"
|
|
209
206
|
- ".github/workflows/remove-labels-on-activity.yml"
|
|
210
207
|
- ".rubocop.yml"
|
|
211
208
|
- ".ruby-version"
|
|
@@ -240,6 +237,7 @@ files:
|
|
|
240
237
|
- lib/shopify_api/auth.rb
|
|
241
238
|
- lib/shopify_api/auth/associated_user.rb
|
|
242
239
|
- lib/shopify_api/auth/auth_scopes.rb
|
|
240
|
+
- lib/shopify_api/auth/client_assertion.rb
|
|
243
241
|
- lib/shopify_api/auth/client_credentials.rb
|
|
244
242
|
- lib/shopify_api/auth/jwt_payload.rb
|
|
245
243
|
- lib/shopify_api/auth/oauth.rb
|
|
@@ -262,6 +260,7 @@ files:
|
|
|
262
260
|
- lib/shopify_api/errors/disabled_resource_error.rb
|
|
263
261
|
- lib/shopify_api/errors/feature_deprecated_error.rb
|
|
264
262
|
- lib/shopify_api/errors/http_response_error.rb
|
|
263
|
+
- lib/shopify_api/errors/invalid_client_assertion_error.rb
|
|
265
264
|
- lib/shopify_api/errors/invalid_graphql_request_error.rb
|
|
266
265
|
- lib/shopify_api/errors/invalid_http_request_error.rb
|
|
267
266
|
- lib/shopify_api/errors/invalid_jwt_token_error.rb
|
|
@@ -1703,7 +1702,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
1703
1702
|
- !ruby/object:Gem::Version
|
|
1704
1703
|
version: '0'
|
|
1705
1704
|
requirements: []
|
|
1706
|
-
rubygems_version: 4.0.
|
|
1705
|
+
rubygems_version: 4.0.23
|
|
1707
1706
|
specification_version: 4
|
|
1708
1707
|
summary: The gem for accessing the Shopify API
|
|
1709
1708
|
test_files: []
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
name: Gardener - Investigate Issue
|
|
2
|
-
# Automatically investigates GitHub issues using Claude Code when the
|
|
3
|
-
# 'devtools-investigate-for-gardener' label is applied. Can also be triggered manually
|
|
4
|
-
# via workflow_dispatch for a specific issue number.
|
|
5
|
-
on:
|
|
6
|
-
issues:
|
|
7
|
-
types: [labeled]
|
|
8
|
-
workflow_dispatch:
|
|
9
|
-
inputs:
|
|
10
|
-
issue_number:
|
|
11
|
-
description: 'Issue number to investigate'
|
|
12
|
-
required: true
|
|
13
|
-
type: number
|
|
14
|
-
|
|
15
|
-
permissions:
|
|
16
|
-
contents: read
|
|
17
|
-
issues: read
|
|
18
|
-
pull-requests: read
|
|
19
|
-
checks: read
|
|
20
|
-
|
|
21
|
-
jobs:
|
|
22
|
-
investigate:
|
|
23
|
-
if: >-
|
|
24
|
-
github.event_name == 'workflow_dispatch' ||
|
|
25
|
-
github.event.label.name == 'devtools-investigate-for-gardener'
|
|
26
|
-
runs-on: ubuntu-latest
|
|
27
|
-
steps:
|
|
28
|
-
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
29
|
-
with:
|
|
30
|
-
fetch-depth: 0
|
|
31
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
32
|
-
|
|
33
|
-
- name: Resolve issue number
|
|
34
|
-
id: issue
|
|
35
|
-
run: |
|
|
36
|
-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
37
|
-
NUMBER="${{ github.event.inputs.issue_number }}"
|
|
38
|
-
else
|
|
39
|
-
NUMBER="${{ github.event.issue.number }}"
|
|
40
|
-
fi
|
|
41
|
-
echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
|
|
42
|
-
echo "url=https://github.com/${{ github.repository }}/issues/$NUMBER" >> "$GITHUB_OUTPUT"
|
|
43
|
-
|
|
44
|
-
# Post a starter message so reviewers can follow along while Claude works.
|
|
45
|
-
# `continue-on-error: true` keeps a Slack outage from blocking the run.
|
|
46
|
-
# The response `ts` is stashed for the completion step to thread onto.
|
|
47
|
-
- name: Post investigation start to Slack
|
|
48
|
-
id: start_slack
|
|
49
|
-
continue-on-error: true
|
|
50
|
-
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
|
|
51
|
-
with:
|
|
52
|
-
method: chat.postMessage
|
|
53
|
-
token: ${{ secrets.SLACK_GARDENER_BOT_TOKEN }}
|
|
54
|
-
payload: |-
|
|
55
|
-
{
|
|
56
|
-
"channel": "${{ vars.GARDENER_SLACK_CHANNEL_ID }}",
|
|
57
|
-
"text": "Investigation started for issue #${{ steps.issue.outputs.number }}",
|
|
58
|
-
"blocks": [
|
|
59
|
-
{
|
|
60
|
-
"type": "section",
|
|
61
|
-
"text": {
|
|
62
|
-
"type": "mrkdwn",
|
|
63
|
-
"text": ":mag: *<${{ steps.issue.outputs.url }}|Issue #${{ steps.issue.outputs.number }}>* — investigation starting…\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>"
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
]
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
- name: Investigate issue
|
|
70
|
-
id: investigate
|
|
71
|
-
timeout-minutes: 30
|
|
72
|
-
uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1
|
|
73
|
-
env:
|
|
74
|
-
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
|
|
75
|
-
with:
|
|
76
|
-
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
77
|
-
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
78
|
-
allowed_tools: "Bash(gh issue view *),Bash(gh issue list *),Bash(gh pr list *),Bash(gh pr view *),Bash(gh pr checks *),Bash(gh pr diff *),Bash(gh release list *),Bash(git log *),Bash(git tag -l*),Bash(git show *),Read,Glob,Grep"
|
|
79
|
-
prompt: |
|
|
80
|
-
This is a GitHub Actions report-only investigation run.
|
|
81
|
-
|
|
82
|
-
/investigating-github-issues ${{ steps.issue.outputs.url }}
|
|
83
|
-
|
|
84
|
-
If the skill above did not load, read and follow `.claude/skills/investigating-github-issues/SKILL.md`.
|
|
85
|
-
|
|
86
|
-
Do not modify files, create branches, commit, push, or open pull requests.
|
|
87
|
-
Always return an investigation report as the `report` field in your structured output.
|
|
88
|
-
If you identify a straightforward fix, describe the proposed fix in the report instead of implementing it.
|
|
89
|
-
claude_args: |
|
|
90
|
-
--json-schema '{"type":"object","properties":{"report":{"type":"string","description":"The full investigation report markdown"}},"required":["report"]}'
|
|
91
|
-
|
|
92
|
-
- name: Write report to job summary
|
|
93
|
-
if: always() && steps.investigate.outputs.structured_output
|
|
94
|
-
env:
|
|
95
|
-
STRUCTURED_OUTPUT: ${{ steps.investigate.outputs.structured_output }}
|
|
96
|
-
run: |
|
|
97
|
-
echo "$STRUCTURED_OUTPUT" | jq -r '.report' >> "$GITHUB_STEP_SUMMARY"
|
|
98
|
-
|
|
99
|
-
# Build a single Slack payload — success shape when Claude returned
|
|
100
|
-
# structured output, failure shape otherwise (crash, timeout, cancel,
|
|
101
|
-
# or no structured_output). Running in github-script so we can parse
|
|
102
|
-
# the starter response to thread onto it, and use JSON.stringify to
|
|
103
|
-
# dodge shell-escaping hazards. The report is Claude's trusted
|
|
104
|
-
# structured output, so no HTML-escape pass.
|
|
105
|
-
- name: Prepare Slack payload
|
|
106
|
-
id: slack_payload
|
|
107
|
-
if: always()
|
|
108
|
-
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
|
109
|
-
env:
|
|
110
|
-
STRUCTURED_OUTPUT: ${{ steps.investigate.outputs.structured_output }}
|
|
111
|
-
START_RESPONSE: ${{ steps.start_slack.outputs.response }}
|
|
112
|
-
CHANNEL_ID: ${{ vars.GARDENER_SLACK_CHANNEL_ID }}
|
|
113
|
-
ISSUE_NUMBER: ${{ steps.issue.outputs.number }}
|
|
114
|
-
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
|
115
|
-
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
116
|
-
INVESTIGATE_OUTCOME: ${{ steps.investigate.outcome }}
|
|
117
|
-
with:
|
|
118
|
-
result-encoding: string
|
|
119
|
-
script: |
|
|
120
|
-
const num = process.env.ISSUE_NUMBER;
|
|
121
|
-
const url = process.env.ISSUE_URL;
|
|
122
|
-
const runUrl = process.env.RUN_URL;
|
|
123
|
-
|
|
124
|
-
// Thread onto the starter post when it succeeded; otherwise
|
|
125
|
-
// the result still posts standalone to the channel.
|
|
126
|
-
let threadTs = null;
|
|
127
|
-
try {
|
|
128
|
-
const r = JSON.parse(process.env.START_RESPONSE || '{}');
|
|
129
|
-
if (r.ok && r.ts) threadTs = r.ts;
|
|
130
|
-
} catch (_) {}
|
|
131
|
-
|
|
132
|
-
let text, blocks;
|
|
133
|
-
|
|
134
|
-
// Gate on STRUCTURED_OUTPUT (not report content) so the
|
|
135
|
-
// empty-report edge case still goes through the success path,
|
|
136
|
-
// matching the previous two-step behavior. Wrapped in try/catch
|
|
137
|
-
// so a malformed payload falls through to the failure notice
|
|
138
|
-
// instead of leaving the starter message orphaned.
|
|
139
|
-
let builtSuccess = false;
|
|
140
|
-
if (process.env.STRUCTURED_OUTPUT) {
|
|
141
|
-
try {
|
|
142
|
-
const structured = JSON.parse(process.env.STRUCTURED_OUTPUT);
|
|
143
|
-
const report = structured.report || '';
|
|
144
|
-
|
|
145
|
-
// Top-sections slice: keep everything up to and including
|
|
146
|
-
// the Summary section, drop sections that follow it. Falls
|
|
147
|
-
// back to the full report if the template no longer contains
|
|
148
|
-
// "## Summary".
|
|
149
|
-
const lines = report.split('\n');
|
|
150
|
-
const slice = [];
|
|
151
|
-
let sawSummary = false;
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
if (sawSummary && /^## /.test(line)) break;
|
|
154
|
-
slice.push(line);
|
|
155
|
-
if (/^## Summary/.test(line)) sawSummary = true;
|
|
156
|
-
}
|
|
157
|
-
// Stash fenced code blocks so their contents don't get
|
|
158
|
-
// rewritten by the header/bullet passes below.
|
|
159
|
-
const codeBlocks = [];
|
|
160
|
-
let slackReport = (slice.join('\n').trim() || report)
|
|
161
|
-
.replace(/^```[^\n]*\n([\s\S]*?)\n```$/gm, (_m, c) => {
|
|
162
|
-
codeBlocks.push(c);
|
|
163
|
-
return `\x04${codeBlocks.length - 1}\x05`;
|
|
164
|
-
})
|
|
165
|
-
.replace(/^#{1,6}\s+(.+)$/gm, '*$1*')
|
|
166
|
-
.replace(/\*\*(.+?)\*\*/g, '*$1*')
|
|
167
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>')
|
|
168
|
-
.replace(/^(\s*)- \[x\]\s+/gm, '$1✓ ')
|
|
169
|
-
.replace(/^(\s*)[-*]\s+/gm, '$1• ')
|
|
170
|
-
.replace(/\x04(\d+)\x05/g, (_m, i) => '```\n' + codeBlocks[+i] + '\n```');
|
|
171
|
-
|
|
172
|
-
// Slack section blocks cap at 3000 chars; leave headroom for the footer.
|
|
173
|
-
const footer = `\n\n<${runUrl}|View full report>`;
|
|
174
|
-
const MAX = 2900;
|
|
175
|
-
if (slackReport.length + footer.length > MAX) {
|
|
176
|
-
slackReport = slackReport.slice(0, MAX - footer.length - 1) + '…';
|
|
177
|
-
}
|
|
178
|
-
slackReport += footer;
|
|
179
|
-
|
|
180
|
-
text = `Investigation report for issue #${num}`;
|
|
181
|
-
blocks = [
|
|
182
|
-
{ type: 'section',
|
|
183
|
-
text: { type: 'mrkdwn',
|
|
184
|
-
text: `*<${url}|Issue #${num}>* — Investigation Report` } },
|
|
185
|
-
{ type: 'divider' },
|
|
186
|
-
{ type: 'section',
|
|
187
|
-
text: { type: 'mrkdwn', text: slackReport } }
|
|
188
|
-
];
|
|
189
|
-
builtSuccess = true;
|
|
190
|
-
} catch (e) {
|
|
191
|
-
core.warning(`Failed to build success payload: ${e}; posting failure notice`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (!builtSuccess) {
|
|
196
|
-
const outcome = process.env.INVESTIGATE_OUTCOME;
|
|
197
|
-
// Outcome = 'success' + no structured output means Claude
|
|
198
|
-
// returned without a structured report — distinct from an
|
|
199
|
-
// outright failure.
|
|
200
|
-
const reason = outcome === 'success'
|
|
201
|
-
? 'completed without a report'
|
|
202
|
-
: `${outcome || 'did not complete'}`;
|
|
203
|
-
text = `Investigation failed for issue #${num}`;
|
|
204
|
-
blocks = [
|
|
205
|
-
{ type: 'section',
|
|
206
|
-
text: { type: 'mrkdwn',
|
|
207
|
-
text: `:x: *<${url}|Issue #${num}>* — investigation ${reason}. <${runUrl}|View run>` } }
|
|
208
|
-
];
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const payload = { channel: process.env.CHANNEL_ID, text, blocks };
|
|
212
|
-
if (threadTs) {
|
|
213
|
-
payload.thread_ts = threadTs;
|
|
214
|
-
// Broadcasts the threaded reply back to the channel so the
|
|
215
|
-
// summary shows up inline, not only for thread subscribers.
|
|
216
|
-
payload.reply_broadcast = true;
|
|
217
|
-
}
|
|
218
|
-
return JSON.stringify(payload);
|
|
219
|
-
|
|
220
|
-
- name: Post to Slack
|
|
221
|
-
if: always() && steps.slack_payload.outputs.result
|
|
222
|
-
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
|
|
223
|
-
with:
|
|
224
|
-
method: chat.postMessage
|
|
225
|
-
token: ${{ secrets.SLACK_GARDENER_BOT_TOKEN }}
|
|
226
|
-
payload: '${{ steps.slack_payload.outputs.result }}'
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
name: Gardener - Notify Event
|
|
2
|
-
# Tiny event capturer: stashes the triggering issue/PR payload as an artifact
|
|
3
|
-
# for `gardener-notify-slack.yml` to pick up via workflow_run.
|
|
4
|
-
#
|
|
5
|
-
# Why two workflows? When Dependabot triggers a workflow, GitHub forces
|
|
6
|
-
# GITHUB_TOKEN to read-only and hides Actions secrets — so labeling and
|
|
7
|
-
# Slack posting from this workflow would fail on every Dependabot PR. A
|
|
8
|
-
# workflow_run-triggered follow-up runs in the default-branch context with
|
|
9
|
-
# full permissions and secret access, regardless of the upstream actor.
|
|
10
|
-
#
|
|
11
|
-
# Uses pull_request_target so fork-opened PRs still produce an artifact.
|
|
12
|
-
# No code is checked out here; this workflow only reads the pre-parsed
|
|
13
|
-
# event payload, so there is no pwn-request surface.
|
|
14
|
-
on:
|
|
15
|
-
issues:
|
|
16
|
-
types: [opened, labeled]
|
|
17
|
-
pull_request_target:
|
|
18
|
-
types: [opened, labeled]
|
|
19
|
-
|
|
20
|
-
permissions:
|
|
21
|
-
contents: read
|
|
22
|
-
|
|
23
|
-
jobs:
|
|
24
|
-
capture:
|
|
25
|
-
if: github.event.action == 'opened' || github.event.label.name == 'devtools-gardener'
|
|
26
|
-
runs-on: ubuntu-latest
|
|
27
|
-
steps:
|
|
28
|
-
- name: Stash event payload
|
|
29
|
-
run: cp "$GITHUB_EVENT_PATH" event.json
|
|
30
|
-
|
|
31
|
-
- uses: actions/upload-artifact@v4
|
|
32
|
-
with:
|
|
33
|
-
name: gardener-event
|
|
34
|
-
path: event.json
|
|
35
|
-
retention-days: 1
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
name: Gardener - Notify Slack
|
|
2
|
-
# Runs after `Gardener - Notify Event` completes and does the real work:
|
|
3
|
-
# applies the devtools-gardener label and posts a repo-prefixed linked title to Slack.
|
|
4
|
-
#
|
|
5
|
-
# The workflow_run trigger runs this job in the default-branch context with
|
|
6
|
-
# full GITHUB_TOKEN permissions and Actions secret access — this is what
|
|
7
|
-
# lets it succeed for Dependabot-opened PRs, where the upstream event
|
|
8
|
-
# workflow can't label or reach secrets directly.
|
|
9
|
-
on:
|
|
10
|
-
workflow_run:
|
|
11
|
-
workflows: ['Gardener - Notify Event']
|
|
12
|
-
types: [completed]
|
|
13
|
-
|
|
14
|
-
permissions:
|
|
15
|
-
contents: read
|
|
16
|
-
issues: write
|
|
17
|
-
pull-requests: write
|
|
18
|
-
actions: read
|
|
19
|
-
|
|
20
|
-
jobs:
|
|
21
|
-
notify:
|
|
22
|
-
# `conclusion == success` also covers runs where the capture job was
|
|
23
|
-
# skipped by its `if` gate (no matching label, etc.) — in that case
|
|
24
|
-
# no artifact was uploaded, so the download step below no-ops.
|
|
25
|
-
if: github.event.workflow_run.conclusion == 'success'
|
|
26
|
-
runs-on: ubuntu-latest
|
|
27
|
-
steps:
|
|
28
|
-
- name: Download event payload
|
|
29
|
-
id: download
|
|
30
|
-
continue-on-error: true
|
|
31
|
-
uses: actions/download-artifact@v4
|
|
32
|
-
with:
|
|
33
|
-
name: gardener-event
|
|
34
|
-
run-id: ${{ github.event.workflow_run.id }}
|
|
35
|
-
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
36
|
-
|
|
37
|
-
- name: Add devtools-gardener label
|
|
38
|
-
if: steps.download.outcome == 'success'
|
|
39
|
-
env:
|
|
40
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
41
|
-
GH_REPO: ${{ github.repository }}
|
|
42
|
-
run: |
|
|
43
|
-
ACTION=$(jq -r '.action' event.json)
|
|
44
|
-
# On `labeled` events the label is already there — skip.
|
|
45
|
-
if [ "$ACTION" != "opened" ]; then
|
|
46
|
-
exit 0
|
|
47
|
-
fi
|
|
48
|
-
NUMBER=$(jq -r '(.issue // .pull_request).number' event.json)
|
|
49
|
-
if jq -e 'has("pull_request")' event.json > /dev/null; then
|
|
50
|
-
gh pr edit "$NUMBER" --add-label devtools-gardener
|
|
51
|
-
else
|
|
52
|
-
gh issue edit "$NUMBER" --add-label devtools-gardener
|
|
53
|
-
fi
|
|
54
|
-
|
|
55
|
-
- name: Post to Slack
|
|
56
|
-
if: steps.download.outcome == 'success'
|
|
57
|
-
continue-on-error: true
|
|
58
|
-
env:
|
|
59
|
-
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GARDENER_BOT_TOKEN }}
|
|
60
|
-
SLACK_CHANNEL_ID: ${{ vars.GARDENER_SLACK_CHANNEL_ID }}
|
|
61
|
-
run: |
|
|
62
|
-
jq \
|
|
63
|
-
--arg channel "$SLACK_CHANNEL_ID" \
|
|
64
|
-
'
|
|
65
|
-
def escape: gsub("&";"&") | gsub("<";"<") | gsub(">";">");
|
|
66
|
-
|
|
67
|
-
(.issue // .pull_request) as $i
|
|
68
|
-
| { channel: $channel,
|
|
69
|
-
text: "\(.repository.name) · <\($i.html_url)|\($i.title | escape)>",
|
|
70
|
-
unfurl_links: false,
|
|
71
|
-
unfurl_media: false }
|
|
72
|
-
' event.json | curl -sf -X POST \
|
|
73
|
-
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
|
74
|
-
-H 'Content-type: application/json; charset=utf-8' \
|
|
75
|
-
-d @- https://slack.com/api/chat.postMessage
|