shopify_api 16.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ef28917f2c106b9f37d8bbe2d84a870da064c05dc9a1c962adabc3e1c554990
4
- data.tar.gz: 3f09f7a81bd0fb124e449c2025a228588a0d2e23f9ccff7db26ef321e33685f8
3
+ metadata.gz: cbd548afc86fb40ff24448ff45e03de8cc03f447ccd8e720f547fa68928c0bff
4
+ data.tar.gz: 7fa251cefb79e6425b74c30c0249d6f8e814c8d7c6a4e96e9f8c97c539aae54c
5
5
  SHA512:
6
- metadata.gz: 617d78c0c104967dfda7f676807563d1c41e01c76ffa063d66728c77f2463fc13cc8e7b1316743c09997c793c98a4c679b1c2848098864131ddabf09653ba509
7
- data.tar.gz: 0c1ec1644cca56fcb2df2f5d55fefa5f692cbe7f8b2800e398a5cb1e7be462ff84e9d14065090e2441f0c725565c9916919b2fa1fd7c965e63e00fbbe8ef344a
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: close-issues
11
- uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3.6.3
15
+ - name: Close inactive issues
16
+ uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
12
17
  with:
13
- actions: 'close-issues'
14
- token: ${{ secrets.GITHUB_TOKEN }}
15
- labels: 'Waiting for Response'
16
- inactive-day: 14
17
- body: |
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,20 @@
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
+
15
+ ## 16.3.1 (2026-10-22)
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.
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.
18
+ - [#1472](https://github.com/Shopify/shopify-api-ruby/pull/1472) `ShopifyAPI::Utils::ShopValidator` now rejects hosts with non-hostname characters and reads the store handle from the segment after `/store/` in Admin URLs (`admin.shopify.com/store/<handle>`), rejecting a missing or invalid handle.
19
+
6
20
  ## 16.3.0 (2026-08-04)
7
21
  - [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Add `ShopifyAPI::Utils::ShopValidator` with `sanitize_shop_domain` and `sanitize!`.
8
22
  - [#1443](https://github.com/Shopify/shopify-api-ruby/pull/1443) Derive the target shop for `ShopifyAPI::Auth::TokenExchange.exchange_token` from the session token's `dest` claim. The `shop` argument is now deprecated and will be removed in the next major version.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- shopify_api (16.3.0)
4
+ shopify_api (16.4.0)
5
5
  activesupport
6
6
  addressable (~> 2.7)
7
7
  concurrent-ruby
data/docs/usage/oauth.md CHANGED
@@ -277,18 +277,29 @@ end
277
277
  ### Client Credentials Grant
278
278
 
279
279
  > [!NOTE]
280
- > You can only use the client credentials grant when building apps for your own organization.
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
- exchange the [app's client ID and client secret](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets) for an access token.
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
- client_id: ShopifyAPI::Context.api_key,
29
- client_secret: ShopifyAPI::Context.api_secret_key,
30
- grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
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: "/admin/oauth")
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: "access_token",
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
@@ -33,6 +33,8 @@ module ShopifyAPI
33
33
  end
34
34
  raise Errors::UnsupportedOauthError, "Cannot perform OAuth for private apps." if Context.private?
35
35
 
36
+ shop = Utils::ShopValidator.sanitize!(shop)
37
+
36
38
  state = SecureRandom.alphanumeric(NONCE_LENGTH)
37
39
 
38
40
  cookie = SessionCookie.new(value: state, expires: Time.now + 60)
@@ -73,7 +73,8 @@ module ShopifyAPI
73
73
  ),
74
74
  )
75
75
  rescue ShopifyAPI::Errors::HttpResponseError => error
76
- if error.code == 400 && error.response.body["error"] == "invalid_subject_token"
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
 
@@ -1,10 +1,19 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "addressable/uri"
5
+
4
6
  module ShopifyAPI
5
7
  module Auth
6
8
  extend T::Sig
7
9
 
10
+ # Hostname characters, plus an optional port. Anything else before the first "/" is rejected.
11
+ EMBEDDED_HOST_AUTHORITY_REGEX = T.let(/\A[a-zA-Z0-9._-]+(:\d{1,5})?\z/, Regexp)
12
+
13
+ # Printable ASCII only: no whitespace, control bytes or non-ASCII bytes.
14
+ EMBEDDED_HOST_ALLOWED_BYTES_REGEX = T.let(/\A[\x21-\x7e]+\z/n, Regexp)
15
+ private_constant :EMBEDDED_HOST_AUTHORITY_REGEX, :EMBEDDED_HOST_ALLOWED_BYTES_REGEX
16
+
8
17
  class << self
9
18
  extend T::Sig
10
19
 
@@ -19,8 +28,29 @@ module ShopifyAPI
19
28
  end
20
29
 
21
30
  decoded_host = Base64.decode64(host)
31
+ unless trusted_embedded_host?(decoded_host)
32
+ raise Errors::InvalidShopError, "host must decode to a trusted Shopify Admin host"
33
+ end
34
+
22
35
  "https://#{decoded_host}/apps/#{Context.api_key}"
23
36
  end
37
+
38
+ private
39
+
40
+ sig { params(decoded_host: String).returns(T::Boolean) }
41
+ def trusted_embedded_host?(decoded_host)
42
+ return false unless decoded_host.b.match?(EMBEDDED_HOST_ALLOWED_BYTES_REGEX)
43
+
44
+ authority = T.must(decoded_host.split("/", 2).first)
45
+ return false unless authority.match?(EMBEDDED_HOST_AUTHORITY_REGEX)
46
+
47
+ uri = Addressable::URI.parse("https://#{decoded_host}")
48
+ return false unless uri.userinfo.nil? && uri.authority == authority
49
+
50
+ !Utils::ShopValidator.sanitize_shop_domain(uri.to_s).nil?
51
+ rescue Addressable::URI::InvalidURIError
52
+ false
53
+ end
24
54
  end
25
55
  end
26
56
  end
@@ -32,11 +32,16 @@ module ShopifyAPI
32
32
  end
33
33
  end
34
34
 
35
- sig { params(request: HttpRequest, response_as_struct: T::Boolean).returns(HttpResponse) }
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
- res = T.cast(HTTParty.send(
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
- body["errors"] = response.body["errors"] if response.body["errors"]
115
- body["error"] = response.body["error"] if response.body["error"]
116
- body["error_description"] = response.body["error_description"] if response.body["error"]
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
@@ -0,0 +1,9 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ShopifyAPI
5
+ module Errors
6
+ class InvalidClientAssertionError < StandardError
7
+ end
8
+ end
9
+ end
@@ -17,6 +17,10 @@ module ShopifyAPI
17
17
  T::Array[String],
18
18
  )
19
19
 
20
+ HOSTNAME_REGEX = T.let(/\A[a-z0-9_-]+(\.[a-z0-9_-]+)*\.?\z/, Regexp)
21
+ STORE_HANDLE_REGEX = T.let(/\A[a-z0-9][a-z0-9-]*\z/, Regexp)
22
+ private_constant :HOSTNAME_REGEX, :STORE_HANDLE_REGEX
23
+
20
24
  class << self
21
25
  extend T::Sig
22
26
 
@@ -29,6 +33,7 @@ module ShopifyAPI
29
33
  def sanitize_shop_domain(shop_domain, myshopify_domain: nil)
30
34
  uri = uri_from_shop_domain(shop_domain, myshopify_domain)
31
35
  return nil if uri.nil? || uri.host.nil? || uri.host.empty?
36
+ return nil unless T.cast(uri.host, String).match?(HOSTNAME_REGEX)
32
37
 
33
38
  trusted_domains(myshopify_domain).each do |trusted_domain|
34
39
  host = T.cast(uri.host, String)
@@ -107,9 +112,13 @@ module ShopifyAPI
107
112
  T.cast(uri.host, String).split(".").first == "admin"
108
113
  end
109
114
 
110
- sig { params(uri: Addressable::URI).returns(String) }
115
+ sig { params(uri: Addressable::URI).returns(T.nilable(String)) }
111
116
  def myshopify_domain_from_unified_admin(uri)
112
- shop = uri.path.to_s.split("/").last
117
+ segments = uri.path.to_s.split("/")
118
+ store_index = segments.index("store")
119
+ shop = store_index && segments[store_index + 1]
120
+ return unless shop&.match?(STORE_HANDLE_REGEX)
121
+
113
122
  "#{shop}.myshopify.com"
114
123
  end
115
124
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module ShopifyAPI
5
- VERSION = "16.3.0"
5
+ VERSION = "16.4.0"
6
6
  end
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.3.0
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.16
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,116 +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 summary 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
- KIND=$(jq -r 'if has("pull_request") then "PR" else "Issue" end' event.json)
63
- # Pull the body out, truncate, then convert GitHub Markdown to
64
- # Slack mrkdwn. Links and fenced code blocks are stashed before
65
- # the HTML-escape pass so their contents survive verbatim (a `&`
66
- # inside a URL must stay raw, and code content shouldn't be
67
- # mangled). Blockquote `> ` markers are also stashed so the
68
- # `>` → `&gt;` escape doesn't break them. Everything else is
69
- # HTML-escaped so user-supplied `<`, `>`, `&` can't collide
70
- # with Slack link syntax or injected mentions like <!channel>.
71
- BODY=$(jq -r '(.issue // .pull_request).body // ""' event.json)
72
- if [ ${#BODY} -gt 1000 ]; then
73
- BODY="${BODY:0:1000}…"
74
- fi
75
- BODY=$(printf '%s' "$BODY" | perl -0777 -pe '
76
- my @u;
77
- s{\[([^\]]+)\]\(([^)]+)\)}{push @u, $2; "\x01$#u\x02$1\x03"}ge;
78
- my @c;
79
- s{^```[^\n]*\n(.*?)\n```$}{push @c, $1; "\x04$#c\x05"}gems;
80
- s/^> /\x06/gm;
81
- s/^#{1,6}\s+(.+)$/*$1*/gm;
82
- s/\*\*(.+?)\*\*/*$1*/g;
83
- s/^(\s*)- \[x\]\s+/$1✓ /gm;
84
- s/^(\s*)[-*]\s+/$1• /gm;
85
- s/&/&amp;/g;
86
- s/</&lt;/g;
87
- s/>/&gt;/g;
88
- s/\x06/> /g;
89
- s{\x01(\d+)\x02(.*?)\x03}{"<$u[$1]|$2>"}ge;
90
- s{\x04(\d+)\x05}{"```\n$c[$1]\n```"}ge;
91
- ')
92
- jq \
93
- --arg channel "$SLACK_CHANNEL_ID" \
94
- --arg kind "$KIND" \
95
- --arg body "$BODY" \
96
- '
97
- def escape: gsub("&";"&amp;") | gsub("<";"&lt;") | gsub(">";"&gt;");
98
-
99
- (.issue // .pull_request) as $i
100
- | ([$i.labels[]?.name | select(. != "devtools-gardener")]
101
- | map("`\(.)`") | join(" ")) as $labels
102
- | (if $kind == "PR"
103
- then " · \($i.changed_files) files, +\($i.additions)/-\($i.deletions)"
104
- + (if $i.draft then " · draft" else "" end)
105
- else "" end) as $meta
106
- | [ "*<\($i.html_url)|\($kind) #\($i.number)>* — \(($i.title | escape))",
107
- "_opened by \($i.user.login)\($meta)_" ]
108
- + (if $body != "" then [$body] else [] end)
109
- + (if $labels != "" then [$labels] else [] end)
110
- | join("\n") as $msg
111
- | { channel: $channel, text: "\($kind) #\($i.number): \($i.title)",
112
- blocks: [{ type: "section", text: { type: "mrkdwn", text: $msg } }] }
113
- ' event.json | curl -sf -X POST \
114
- -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
115
- -H 'Content-type: application/json; charset=utf-8' \
116
- -d @- https://slack.com/api/chat.postMessage