genlayer 0.39.0 → 0.39.2

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.
@@ -0,0 +1,740 @@
1
+ # Source-of-truth E2E pipeline workflow.
2
+ #
3
+ # This file is BOTH:
4
+ # - The workflow that runs in this repo (genlayer-e2e) for dispatch /
5
+ # local testing.
6
+ # - The file synced verbatim to every consumer repo as
7
+ # .github/workflows/e2e.yml (sync-template.yaml owns the fan-out).
8
+ #
9
+ # Consumers don't carry a separate thin caller — keeping the consumer's
10
+ # workflow file BYTE-IDENTICAL to this one means:
11
+ # - Sidebar tiles stay flat: `acknowledge / register`, `plan / action`,
12
+ # `build / discover`, `genlayer-core / shard ... / e2e`, `result` —
13
+ # no wrapper-job prefix. A consumer-side thin caller would force
14
+ # `<wrapper> /` on every inner tile (see #386 follow-up where
15
+ # deleting e2e-harness.yml restored the flat layout).
16
+ # - Behavior changes ship via one sync PR per consumer, instead of
17
+ # each consumer hand-editing their wrapper.
18
+ #
19
+ # Two trigger paths:
20
+ # - `issue_comment` — PR comment `/run-e2e [profile] [track] [scope]`
21
+ # on the consumer's repo. The acknowledge job's `if:` gates on
22
+ # author-association so only members/owners/collaborators can fire
23
+ # the pipeline.
24
+ # - `workflow_dispatch` — manual / debug in this repo (UI dropdowns).
25
+ # Acknowledge is skipped (no PR comment context); plan/build/waves
26
+ # run with workflow_dispatch input values.
27
+ #
28
+ # All internal `uses:` references are cross-repo
29
+ # (`genlayerlabs/genlayer-e2e/.github/workflows/X.yml@main`) so the same
30
+ # file works whether it lives in this repo or in a synced consumer.
31
+ # Feature-branch testing requires sedding `@main` → `@<branch>` on the
32
+ # inner uses; see feedback_branch_pin_for_testing.md.
33
+ #
34
+ # Sync-time hack — `on: issue_comment:` injection
35
+ # ------------------------------------------------
36
+ # The source-of-truth file in genlayer-e2e does NOT declare an
37
+ # `on: issue_comment:` trigger. If it did, this workflow would fire on
38
+ # EVERY comment in genlayer-e2e's own PRs (coderabbit, dependabot, …)
39
+ # and consume a 3-second skipped "noop" run per comment — visible
40
+ # clutter on the Actions list.
41
+ #
42
+ # `sync-templates.sh` injects the `issue_comment:` trigger block at
43
+ # sync time at the `# SYNC_INJECT(issue_comment)` marker below. The
44
+ # synced consumer copy gets the trigger; genlayer-e2e's source never
45
+ # does. Manual debug here still works via the `workflow_dispatch:`
46
+ # trigger which IS declared.
47
+ #
48
+ # The runtime expressions below (run-name, concurrency, acknowledge's
49
+ # `if:`, build/wave `pr-number` / `comment-id` coalesces) all reference
50
+ # `github.event_name == 'issue_comment'` and `github.event.comment.*`
51
+ # — those evaluate cleanly to false / empty on the workflow_dispatch
52
+ # path in genlayer-e2e, so they stay unconditional and work for both
53
+ # consumer and source contexts. Only the trigger declaration itself
54
+ # is injected.
55
+
56
+ name: E2E Pipeline
57
+
58
+ # Tag every run with a descriptive title:
59
+ # issue_comment /run-e2e: PR #<n> /run-e2e
60
+ # workflow_dispatch: E2E Test <profile>/<track>/<scope>
61
+ # anything else: noop <run_id>
62
+ #
63
+ # Manual dispatch is only available on the source repo (genlayer-e2e
64
+ # itself, never on a synced consumer), and is intentionally unrestricted
65
+ # — there's no per-actor concurrency group, so a developer can fire
66
+ # several test runs side-by-side. Putting profile/track/scope in the
67
+ # title makes those runs distinguishable at a glance on the Actions
68
+ # list (where the bare run_id was previously opaque).
69
+ run-name: >-
70
+ ${{ (github.event_name == 'issue_comment'
71
+ && startsWith(github.event.comment.body, '/run-e2e')
72
+ && format('PR #{0} /run-e2e', github.event.issue.number))
73
+ || (github.event_name == 'workflow_dispatch'
74
+ && format('E2E Test {0}/{1}/{2}/{3}', inputs.profile, inputs.track, inputs.scope, inputs.stack))
75
+ || format('noop {0}', github.run_id) }}
76
+
77
+ on:
78
+ issue_comment:
79
+ types: [created]
80
+ workflow_dispatch:
81
+ inputs:
82
+ profile:
83
+ description: Profile name (must be a top-level key in profiles.json)
84
+ required: false
85
+ default: default
86
+ type: choice
87
+ options:
88
+ - default
89
+ - testnet
90
+ track:
91
+ description: Matrix track (must exist as matrix/<track>.yaml)
92
+ required: false
93
+ default: v0.5
94
+ type: choice
95
+ options:
96
+ - v0.5
97
+ scope:
98
+ description: >
99
+ Wave-plan scope filter. `all` runs every wave (default);
100
+ `core` runs only wave-1 (genlayer-node / test-core);
101
+ `tooling` runs waves 2-4 (SDKs + explorer + testing-suite + wallet).
102
+ Out-of-scope waves cascade-skip with reason "scope".
103
+ required: false
104
+ default: all
105
+ type: choice
106
+ options:
107
+ - all
108
+ - core
109
+ - tooling
110
+ stack:
111
+ description: >
112
+ Stack-target filter. `all` exercises every declared stack
113
+ (default); `dev-env` runs only the genlayer-dev-env variants;
114
+ `studio` runs only the GenLayer Studio variants. Components
115
+ whose declared `stack-targets` don't include the chosen
116
+ stack cascade-skip with reason "stack".
117
+ required: false
118
+ default: all # SYNC_INJECT(default_stack)
119
+ type: choice
120
+ options:
121
+ - all
122
+ - dev-env
123
+ - studio
124
+
125
+ permissions:
126
+ contents: read
127
+ id-token: write
128
+ issues: write
129
+ checks: write
130
+ actions: write
131
+ pull-requests: write
132
+
133
+ # /run-e2e triggers share a per-PR group so a new comment cancels the
134
+ # previous in-progress run. Other issue_comment events (and
135
+ # workflow_dispatch) get a unique group so they never queue behind a
136
+ # hung /run-e2e.
137
+ concurrency:
138
+ group: >-
139
+ ${{ (github.event_name == 'issue_comment'
140
+ && startsWith(github.event.comment.body, '/run-e2e'))
141
+ && format('e2e-{0}-pr-{1}', github.repository, github.event.issue.number)
142
+ || format('e2e-noop-{0}', github.run_id) }}
143
+ cancel-in-progress: true
144
+
145
+ env:
146
+ GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID || 'devops-infra-428314' }}
147
+ GCP_WIF_PROVIDER: ${{ vars.GCP_WIF_PROVIDER || 'projects/795309574007/locations/global/workloadIdentityPools/ci-core-e2e-runner/providers/ci-core-e2e-runner' }}
148
+ GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT || 'ci-core-e2e-runner@devops-workload-identities.iam.gserviceaccount.com' }}
149
+ GCP_SECRET_APP_CLIENT_ID: ${{ vars.GCP_SECRET_APP_CLIENT_ID || 'ci-core-e2e-runner-app-client-id' }}
150
+ GCP_SECRET_APP_PRIVATE_KEY: ${{ vars.GCP_SECRET_APP_PRIVATE_KEY || 'ci-core-e2e-runner-app-private-key' }}
151
+ # Suppress notify-outcome's compact PR banner. Detailed results
152
+ # table still posted. Remove to re-enable.
153
+ E2E_REPORT_SKIP_RESULT: 'true'
154
+
155
+ jobs:
156
+ # ===========================================================================
157
+ # acknowledge — PR-side bookkeeping and comment tokenization.
158
+ #
159
+ # The `if:` guard lives here (not inside e2e-acknowledge.yml) because
160
+ # only the caller can guard on `github.event_name` + comment author
161
+ # association before the reusable workflow is even resolved. On
162
+ # workflow_dispatch, `github.event.issue` is null → guard false →
163
+ # acknowledge is skipped (and the plan job's `if:` opts back in for
164
+ # the dispatch path).
165
+ # ===========================================================================
166
+ acknowledge:
167
+ # PR-state guard: refuse /run-e2e on closed / merged PRs. After a
168
+ # PR merges, its head branch is typically deleted, which breaks
169
+ # every "content at PR head" fetch (third_party version files,
170
+ # target-repo matrix.yaml) — the resolver silently falls through
171
+ # to the baseline matrix and downstream cache keys drift off
172
+ # whatever the baseline's branch tip happens to be at that moment.
173
+ # If you actually need to retest, re-run on main or open a fresh
174
+ # PR with the same content.
175
+ if: >-
176
+ github.event.issue.pull_request
177
+ && github.event.issue.state == 'open'
178
+ && startsWith(github.event.comment.body, '/run-e2e')
179
+ && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association)
180
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-acknowledge.yml@main
181
+ with:
182
+ comment-id: ${{ github.event.comment.id }}
183
+ comment-body: ${{ github.event.comment.body }}
184
+ issue-number: ${{ github.event.issue.number }}
185
+ target-repo: ${{ github.repository }}
186
+ server-url: ${{ github.server_url }}
187
+ run-id: ${{ github.run_id }}
188
+ secrets: inherit
189
+
190
+ # ===========================================================================
191
+ # plan — pin profile / track / matrix-refs / wave-plans / cache-key.
192
+ # Dual-path internally (PR vs dispatch); see e2e-planner.yml.
193
+ #
194
+ # `if:` opts back in on workflow_dispatch where acknowledge is
195
+ # intentionally skipped. PR-comment path with an unauthorized author
196
+ # (acknowledge skipped because guard failed) does NOT re-enter here —
197
+ # the `github.event_name == 'workflow_dispatch'` clause filters it out.
198
+ # ===========================================================================
199
+ plan:
200
+ needs: acknowledge
201
+ if: |
202
+ !cancelled() &&
203
+ (needs.acknowledge.result == 'success' ||
204
+ (needs.acknowledge.result == 'skipped' && github.event_name == 'workflow_dispatch'))
205
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-planner.yml@main
206
+ with:
207
+ # Coalesce: acknowledge tokens win on the PR-comment path; on
208
+ # workflow_dispatch the tokens are empty and we fall back to
209
+ # the manual choice inputs.
210
+ profile: ${{ needs.acknowledge.outputs.profile-token || inputs.profile }}
211
+ track: ${{ needs.acknowledge.outputs.track-token || inputs.track }}
212
+ scope: ${{ needs.acknowledge.outputs.scope-token || inputs.scope }}
213
+ # SYNC_INJECT(default_stack_fallback) rewrites the literal 'all' on
214
+ # consumer copies (e.g. 'dev-env' for genlayer-node), so an
215
+ # issue_comment `/run-e2e` with no stack token still routes to
216
+ # the per-consumer default — inputs.stack is workflow_dispatch-
217
+ # only and resolves empty on the issue_comment path.
218
+ stack: ${{ needs.acknowledge.outputs.stack-token || inputs.stack || 'all' }} # SYNC_INJECT(default_stack_fallback)
219
+ target-repo: ${{ github.repository }}
220
+ pr-number: ${{ github.event.issue.number || '' }}
221
+ comment-id: ${{ github.event.comment.id || '' }}
222
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
223
+ # acknowledge picks the layered-cache cleavage based on the
224
+ # consumer repo. On workflow_dispatch acknowledge is skipped
225
+ # and its output is empty — the planner's empty default then
226
+ # disables layering (today's pre-Slice-A behaviour).
227
+ pre-build-cache: ${{ needs.acknowledge.outputs.pre-build-cache || '' }}
228
+
229
+ # ===========================================================================
230
+ # build — full stack up + pack to cache. Synthetic PR context on the
231
+ # dispatch path: run_id stands in for pr-number; comment-id /
232
+ # check-run-id stay empty so downstream PR-facing steps are no-ops.
233
+ # ===========================================================================
234
+ build:
235
+ name: build (dev-env)
236
+ needs: [acknowledge, plan]
237
+ # Without an explicit `if:`, GHA's implicit `success()` would require
238
+ # acknowledge to have succeeded — but acknowledge is intentionally
239
+ # skipped on the workflow_dispatch path. Mirror the wave jobs and
240
+ # gate only on plan succeeding.
241
+ #
242
+ # Skip when the stack filter omits dev-env. `/run-e2e studio` makes
243
+ # every wave row's stack-target='studio' (build-studio handles
244
+ # those), so the dev-env bundle isn't needed. Mirrors build-studio's
245
+ # inverse gate below.
246
+ if: |
247
+ !cancelled() &&
248
+ needs.plan.result == 'success' &&
249
+ contains(needs.plan.outputs.stack-config, '"target":"dev-env"')
250
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build.yml@main
251
+ with:
252
+ track: ${{ github.ref_name }}
253
+ profile: ${{ needs.plan.outputs.profile }}
254
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
255
+ consensus-ref: ${{ needs.plan.outputs.consensus-ref }}
256
+ genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }}
257
+ genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }}
258
+ harness-ref: ${{ needs.plan.outputs.harness-ref }}
259
+ harness-sha: ${{ needs.plan.outputs.harness-sha }}
260
+ consensus-sha: ${{ needs.plan.outputs.consensus-sha }}
261
+ genlayer-node-sha: ${{ needs.plan.outputs.genlayer-node-sha }}
262
+ resolved-shas-json: ${{ needs.plan.outputs.resolved-shas-json }}
263
+ build-cache-key: ${{ needs.plan.outputs.build-cache-key }}
264
+ full-cache-key: ${{ needs.plan.outputs.full-cache-key }}
265
+ pre-build-cache: ${{ needs.plan.outputs.pre-build-cache }}
266
+ # Mirrors matrix/<track>.yaml shape ({core, harness, tooling}). The
267
+ # conclusion job's Emit build summary step parses this with jq
268
+ # to render the full component Refs sub-list in the Execution
269
+ # block. Trailing JSON commas would be invalid — keep the same
270
+ # field set as the matrix file.
271
+ matrix-json: >-
272
+ {"core":{"genlayer-node":"${{ needs.plan.outputs.genlayer-node-ref }}","genlayer-consensus":"${{ needs.plan.outputs.consensus-ref }}","genvm":"${{ needs.plan.outputs.genvm-version }}"},"harness":{"genlayer-dev-env":"${{ needs.plan.outputs.harness-ref }}"},"tooling":{"genlayer-js":"${{ needs.plan.outputs.genlayer-js-ref }}","genlayer-py":"${{ needs.plan.outputs.genlayer-py-ref }}","genlayer-cli":"${{ needs.plan.outputs.genlayer-cli-ref }}","genlayer-studio":"${{ needs.plan.outputs.genlayer-studio-ref }}","genlayer-explorer":"${{ needs.plan.outputs.genlayer-explorer-ref }}","genlayer-testing-suite":"${{ needs.plan.outputs.genlayer-testing-suite-ref }}","genvm-linter":"${{ needs.plan.outputs.genvm-linter-ref }}","genlayer-wallet":"${{ needs.plan.outputs.genlayer-wallet-ref }}"}}
273
+ pr-number: ${{ github.event.issue.number || github.run_id }}
274
+ comment-id: ${{ github.event.comment.id || '' }}
275
+ target-repo: ${{ github.repository }}
276
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
277
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
278
+ github-retry-max: ${{ needs.plan.outputs.github-retry-max }}
279
+ github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }}
280
+ github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }}
281
+
282
+ # ===========================================================================
283
+ # build-studio — bring up a Studio stack and pack it for Studio-target shards.
284
+ # Runs in parallel with the dev-env build. Devenv-target wave rows continue
285
+ # to consume the existing full-cache-key; Studio-target rows restore the
286
+ # per-run Studio bundle produced here.
287
+ # ===========================================================================
288
+ build-studio:
289
+ name: build (studio)
290
+ needs: [acknowledge, plan]
291
+ if: |
292
+ !cancelled() &&
293
+ needs.plan.result == 'success' &&
294
+ contains(needs.plan.outputs.stack-config, '"target":"studio"')
295
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build-studio.yml@main
296
+ with:
297
+ track: ${{ github.ref_name }}
298
+ profile: ${{ needs.plan.outputs.profile }}
299
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
300
+ genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }}
301
+ studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }}
302
+ pr-number: ${{ github.event.issue.number || github.run_id }}
303
+ comment-id: ${{ github.event.comment.id || '' }}
304
+ target-repo: ${{ github.repository }}
305
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
306
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
307
+
308
+ # ===========================================================================
309
+ # Wave jobs — cascade pattern with sentinel-as-skip. Each wave matrix
310
+ # comes from `needs.plan.outputs.wave-plans` (one JSON object folding
311
+ # every per-wave plan array).
312
+ # ===========================================================================
313
+ wave-1:
314
+ # `name:` overrides GHA's default `wave-1 (component, test-task, …)`
315
+ # matrix-tuple display. Reads matrix.job-name (resolved from
316
+ # components.yaml's `job-name` field, falling back to the
317
+ # component key) so the listing reads e.g. `genlayer-core / shard
318
+ # (shard-1, true) / e2e`.
319
+ #
320
+ # When build fails, append "(skipped - build fails)" to the tile so
321
+ # the UI attributes the skip to its root-cause layer. The
322
+ # `matrix.component != 'none'` guard prevents existing sentinel rows
323
+ # (e.g., scope-filtered or impacted-set empty waves) from being
324
+ # relabelled — those keep their baked-in "(skipped - manual)" /
325
+ # "(skipped - scope)" / "(skipped - no scenarios)" label unchanged.
326
+ # Waves 2-4 mirror this pattern with extra upstream-wave clauses
327
+ # reading `needs.wave-K.outputs.failure-label` (the per-component
328
+ # layer tag emitted by e2e-run.yml when that wave's run failed).
329
+ name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || matrix.job-name }}
330
+ needs: [acknowledge, plan, build, build-studio]
331
+ # No `build-status == 'success'` gate — when build fails we want
332
+ # wave-1 to RUN (so the `name:` expression evaluates and tiles
333
+ # render cleanly) but no-op via the sentinel-component override
334
+ # below. Same mechanism as the existing wave-2/3/4 cascade.
335
+ #
336
+ # `!cancelled() &&` bypasses GHA's implicit needs-failure-cascade:
337
+ # when an upstream `needs:` job (here, `build`) fails, GHA defaults
338
+ # to auto-skipping downstream jobs unless their `if:` explicitly
339
+ # opts in via `always()` / `!cancelled()` / `failure()`. A skipped
340
+ # job does NOT have its `name:` expression evaluated, so without
341
+ # this prefix wave-1's tile shows the raw `${{ ... }}` text on
342
+ # build failure. Waves 2-4 already carry the same prefix.
343
+ #
344
+ # resolve-components / build-wave-plans emit a sentinel-row plan
345
+ # (component='none') when no impacted component maps to this wave,
346
+ # so the matrix always has at least one row and GHA can render
347
+ # `${{ matrix.job-name }}` for the placeholder tile (per
348
+ # actions/runner#1985, the unresolved name expression needs a
349
+ # matrix row to bind to). The sentinel's empty features-source /
350
+ # tags / test-task naturally cascade through e2e-run.yml's
351
+ # allocate → shard → conclusion chain to a no-op success — see
352
+ # resolve-components/action.yml for the full cascade explanation.
353
+ if: |
354
+ !cancelled() &&
355
+ needs.plan.result == 'success'
356
+ strategy:
357
+ fail-fast: false
358
+ matrix:
359
+ include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-1'] }}
360
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main
361
+ with:
362
+ # Cascade override: if build did NOT produce a clean verdict,
363
+ # force the sentinel path so wave-1 no-ops cleanly. Same
364
+ # mechanism waves 2-4 use for upstream-wave failures — see
365
+ # e2e-run.yml's sentinel branches.
366
+ component: ${{ ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) && 'none' || matrix.component }}
367
+ track: ${{ github.ref_name }}
368
+ job-name: ${{ matrix.job-name || matrix.component }}
369
+ stack-target: ${{ matrix.stack-target || 'dev-env' }}
370
+ setup-task: ${{ matrix.setup-task }}
371
+ test-task: ${{ matrix.test-task }}
372
+ tags: ${{ matrix.tags }}
373
+ split: ${{ matrix.split }}
374
+ features-source: ${{ matrix.features-source }}
375
+ retry: ${{ matrix.retry }}
376
+ max-shard-split: ${{ matrix.max-shard-split || 0 }}
377
+ failure-tag: ${{ matrix.failure-tag || '' }}
378
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
379
+ genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }}
380
+ consensus-ref: ${{ needs.plan.outputs.consensus-ref }}
381
+ genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }}
382
+ genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }}
383
+ genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }}
384
+ genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }}
385
+ genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }}
386
+ genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }}
387
+ genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }}
388
+ genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }}
389
+ harness-ref: ${{ needs.plan.outputs.harness-ref }}
390
+ harness-sha: ${{ needs.plan.outputs.harness-sha }}
391
+ cache-key: ${{ needs.plan.outputs.full-cache-key }}
392
+ studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }}
393
+ profile: ${{ needs.plan.outputs.profile }}
394
+ pr-number: ${{ github.event.issue.number || github.run_id }}
395
+ comment-id: ${{ github.event.comment.id || '' }}
396
+ target-repo: ${{ github.repository }}
397
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
398
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
399
+ github-retry-max: ${{ needs.plan.outputs.github-retry-max }}
400
+ github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }}
401
+ github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }}
402
+
403
+ wave-2:
404
+ # Cascade-skip label rules (walked in order, first match wins):
405
+ # 1. build failed → "(skipped - build fails)"
406
+ # 2. wave-1's failure-label is non-empty (e.g. "core") →
407
+ # "(skipped - {label} fails)"
408
+ # 3. wave-1 failed but emitted no tag (failure-label empty) →
409
+ # generic "(skipped - fails)"
410
+ # else → matrix.job-name (real run or sentinel-baked label)
411
+ #
412
+ # `failure-label` is e2e-run.yml's per-component output, populated
413
+ # from `inputs.failure-tag` only when the run's test-conclusion is
414
+ # 'failure'. Empty otherwise — so a successful or sentinel-skipped
415
+ # wave doesn't carry a stale tag forward.
416
+ name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-1.result == 'failure') && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }}
417
+ needs: [acknowledge, plan, build, build-studio, wave-1]
418
+ # No cascade gate — wave-2 always runs when plan is OK. If
419
+ # build or wave-1 failed, with.component is overridden to 'none'
420
+ # below, tripping e2e-run.yml's sentinel path (allocate/shard/
421
+ # check-run/retry/cleanup-artifacts skip; conclusion emits
422
+ # 'skipped'). Same flow as manual skip:true.
423
+ if: |
424
+ !cancelled() &&
425
+ needs.plan.result == 'success'
426
+ strategy:
427
+ fail-fast: false
428
+ matrix:
429
+ include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-2'] }}
430
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main
431
+ with:
432
+ # Cascade override: force sentinel path when any upstream layer
433
+ # (build / wave-1) failed. `.result == 'failure'` is the
434
+ # reliable trip signal — failure-label is only used for the
435
+ # tile label content above, not the trip decision.
436
+ component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure') && 'none' || matrix.component }}
437
+ track: ${{ github.ref_name }}
438
+ job-name: ${{ matrix.job-name || matrix.component }}
439
+ stack-target: ${{ matrix.stack-target || 'dev-env' }}
440
+ setup-task: ${{ matrix.setup-task }}
441
+ test-task: ${{ matrix.test-task }}
442
+ tags: ${{ matrix.tags }}
443
+ split: ${{ matrix.split }}
444
+ features-source: ${{ matrix.features-source }}
445
+ retry: ${{ matrix.retry }}
446
+ max-shard-split: ${{ matrix.max-shard-split || 0 }}
447
+ failure-tag: ${{ matrix.failure-tag || '' }}
448
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
449
+ genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }}
450
+ consensus-ref: ${{ needs.plan.outputs.consensus-ref }}
451
+ genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }}
452
+ genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }}
453
+ genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }}
454
+ genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }}
455
+ genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }}
456
+ genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }}
457
+ genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }}
458
+ genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }}
459
+ harness-ref: ${{ needs.plan.outputs.harness-ref }}
460
+ harness-sha: ${{ needs.plan.outputs.harness-sha }}
461
+ cache-key: ${{ needs.plan.outputs.full-cache-key }}
462
+ studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }}
463
+ profile: ${{ needs.plan.outputs.profile }}
464
+ pr-number: ${{ github.event.issue.number || github.run_id }}
465
+ comment-id: ${{ github.event.comment.id || '' }}
466
+ target-repo: ${{ github.repository }}
467
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
468
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
469
+ github-retry-max: ${{ needs.plan.outputs.github-retry-max }}
470
+ github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }}
471
+ github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }}
472
+
473
+ wave-3:
474
+ # See wave-2 for the cascade-skip label semantics. Walk order
475
+ # (first match wins): build → wave-1 failure-label → wave-2
476
+ # failure-label → generic-fallback. The `.result == 'failure'`
477
+ # fallback catches the rare case where an upstream wave failed
478
+ # but didn't emit a failure-tag (e.g. internal job-level error
479
+ # before conclusion ran).
480
+ name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-2.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-2.outputs.failure-label) || (matrix.component != 'none' && (needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure')) && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }}
481
+ needs: [acknowledge, plan, build, build-studio, wave-1, wave-2]
482
+ # No cascade gate — wave-3 always runs. If build or any upstream
483
+ # wave failed, with.component is overridden to 'none' below,
484
+ # tripping e2e-run.yml's sentinel path (no GCE provisioned,
485
+ # conclusion emits 'skipped'). See wave-2 for the full rationale.
486
+ if: |
487
+ !cancelled() &&
488
+ needs.plan.result == 'success'
489
+ strategy:
490
+ fail-fast: false
491
+ matrix:
492
+ include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-3'] }}
493
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main
494
+ with:
495
+ # Cascade override: force sentinel path when any upstream layer
496
+ # (build / wave-1 / wave-2) failed. Uses `.result == 'failure'`
497
+ # for reliability — failure-label is only consumed for the
498
+ # tile label content above, not the trip decision.
499
+ component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure') && 'none' || matrix.component }}
500
+ track: ${{ github.ref_name }}
501
+ job-name: ${{ matrix.job-name || matrix.component }}
502
+ stack-target: ${{ matrix.stack-target || 'dev-env' }}
503
+ setup-task: ${{ matrix.setup-task }}
504
+ test-task: ${{ matrix.test-task }}
505
+ tags: ${{ matrix.tags }}
506
+ split: ${{ matrix.split }}
507
+ features-source: ${{ matrix.features-source }}
508
+ retry: ${{ matrix.retry }}
509
+ max-shard-split: ${{ matrix.max-shard-split || 0 }}
510
+ failure-tag: ${{ matrix.failure-tag || '' }}
511
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
512
+ genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }}
513
+ consensus-ref: ${{ needs.plan.outputs.consensus-ref }}
514
+ genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }}
515
+ genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }}
516
+ genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }}
517
+ genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }}
518
+ genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }}
519
+ genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }}
520
+ genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }}
521
+ genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }}
522
+ harness-ref: ${{ needs.plan.outputs.harness-ref }}
523
+ harness-sha: ${{ needs.plan.outputs.harness-sha }}
524
+ cache-key: ${{ needs.plan.outputs.full-cache-key }}
525
+ studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }}
526
+ profile: ${{ needs.plan.outputs.profile }}
527
+ pr-number: ${{ github.event.issue.number || github.run_id }}
528
+ comment-id: ${{ github.event.comment.id || '' }}
529
+ target-repo: ${{ github.repository }}
530
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
531
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
532
+ github-retry-max: ${{ needs.plan.outputs.github-retry-max }}
533
+ github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }}
534
+ github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }}
535
+
536
+ wave-4:
537
+ # See wave-2 for the cascade-skip label semantics. Walk order
538
+ # (first match wins): build → wave-1 → wave-2 → wave-3 failure-
539
+ # label, then generic-fallback via `.result == 'failure'`.
540
+ name: ${{ (matrix.component != 'none' && ((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success'))) && format('{0} (skipped - build fails)', matrix.job-name) || (matrix.component != 'none' && needs.wave-1.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-1.outputs.failure-label) || (matrix.component != 'none' && needs.wave-2.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-2.outputs.failure-label) || (matrix.component != 'none' && needs.wave-3.outputs.failure-label != '') && format('{0} (skipped - {1} fails)', matrix.job-name, needs.wave-3.outputs.failure-label) || (matrix.component != 'none' && (needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure' || needs.wave-3.result == 'failure')) && format('{0} (skipped - fails)', matrix.job-name) || matrix.job-name }}
541
+ needs: [acknowledge, plan, build, build-studio, wave-1, wave-2, wave-3]
542
+ # No cascade gate — wave-4 always runs. See wave-2 for the
543
+ # cascade-as-sentinel rationale.
544
+ if: |
545
+ !cancelled() &&
546
+ needs.plan.result == 'success'
547
+ strategy:
548
+ fail-fast: false
549
+ matrix:
550
+ include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-4'] }}
551
+ uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main
552
+ with:
553
+ # Cascade override: force sentinel path when any upstream layer
554
+ # (build / wave-1 / wave-2 / wave-3) failed.
555
+ component: ${{ (((matrix.stack-target == 'studio' && needs.build-studio.outputs.build-status != 'success') || (matrix.stack-target != 'studio' && needs.build.outputs.build-status != 'success')) || needs.wave-1.result == 'failure' || needs.wave-2.result == 'failure' || needs.wave-3.result == 'failure') && 'none' || matrix.component }}
556
+ track: ${{ github.ref_name }}
557
+ job-name: ${{ matrix.job-name || matrix.component }}
558
+ stack-target: ${{ matrix.stack-target || 'dev-env' }}
559
+ setup-task: ${{ matrix.setup-task }}
560
+ test-task: ${{ matrix.test-task }}
561
+ tags: ${{ matrix.tags }}
562
+ split: ${{ matrix.split }}
563
+ features-source: ${{ matrix.features-source }}
564
+ retry: ${{ matrix.retry }}
565
+ max-shard-split: ${{ matrix.max-shard-split || 0 }}
566
+ failure-tag: ${{ matrix.failure-tag || '' }}
567
+ genvm-version: ${{ needs.plan.outputs.genvm-version }}
568
+ genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }}
569
+ consensus-ref: ${{ needs.plan.outputs.consensus-ref }}
570
+ genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }}
571
+ genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }}
572
+ genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }}
573
+ genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }}
574
+ genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }}
575
+ genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }}
576
+ genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }}
577
+ genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }}
578
+ harness-ref: ${{ needs.plan.outputs.harness-ref }}
579
+ harness-sha: ${{ needs.plan.outputs.harness-sha }}
580
+ cache-key: ${{ needs.plan.outputs.full-cache-key }}
581
+ studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }}
582
+ profile: ${{ needs.plan.outputs.profile }}
583
+ pr-number: ${{ github.event.issue.number || github.run_id }}
584
+ comment-id: ${{ github.event.comment.id || '' }}
585
+ target-repo: ${{ github.repository }}
586
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }}
587
+ head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }}
588
+ github-retry-max: ${{ needs.plan.outputs.github-retry-max }}
589
+ github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }}
590
+ github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }}
591
+
592
+ # ===========================================================================
593
+ # result — final guard that makes the workflow's conclusion reflect
594
+ # the REAL outcome. Mirrors `plan` at the start (plan → build →
595
+ # waves → result).
596
+ #
597
+ # Per-wave verdict comes from each wave's `test-conclusion` output
598
+ # (sourced from e2e-run.yml's `conclusion` job), which reflects retry
599
+ # recovery — so a first-try shard failure that retry recovered
600
+ # counts as 'success'. When test-conclusion is empty (wave was
601
+ # skipped or never reached the conclusion job), fall back to GHA's
602
+ # `.result` for the rough verdict.
603
+ # ===========================================================================
604
+ result:
605
+ needs:
606
+ - acknowledge
607
+ - plan
608
+ - build
609
+ - build-studio
610
+ - wave-1
611
+ - wave-2
612
+ - wave-3
613
+ - wave-4
614
+ # `always() && plan.result != 'skipped'` rather than bare `always()`:
615
+ # noop issue_comments (any non-/run-e2e comment in this repo, since
616
+ # the workflow lives at `on: issue_comment`) skip acknowledge → plan
617
+ # → build → waves all the way down. Without this guard, result still
618
+ # runs, and its env block resolves
619
+ # `toJson(fromJson(needs.plan.outputs.wave-plans)['wave-N'])` against
620
+ # plan's empty output → fromJson('') fails the template at
621
+ # job-start (see run 26223668789). Letting result skip on
622
+ # plan==skipped keeps the noop pipeline clean — `acknowledge / plan /
623
+ # build / waves / result` all skipped, no red marker.
624
+ if: always() && needs.plan.result != 'skipped'
625
+ runs-on: ubuntu-latest
626
+ steps:
627
+ # App token for the final notify-outcome — completing the
628
+ # check-run + swapping the 👀 reaction requires the App token
629
+ # (consumer's GITHUB_TOKEN can't update a check-run the App
630
+ # created). Cross-repo `uses:` because no checkout has run yet.
631
+ - name: Generate GitHub App token
632
+ id: app-token
633
+ uses: genlayerlabs/genlayer-e2e/.github/actions/gcp-app-token@main
634
+
635
+ # App token, not the default github.token: when this workflow is
636
+ # synced to a consumer, github.token is the CONSUMER's repo-scoped
637
+ # token and can't read the private genlayer-e2e repo (exit 128
638
+ # auth failure on the actions/checkout step — observed on
639
+ # genlayer-node run 26240246363).
640
+ - uses: actions/checkout@v6
641
+ with:
642
+ repository: genlayerlabs/genlayer-e2e
643
+ ref: main
644
+ token: ${{ steps.app-token.outputs.token }}
645
+
646
+ # Per-(component, stack-target) verdict artifacts uploaded by
647
+ # each e2e-run.yml conclusion job. One artifact per matrix row
648
+ # — pattern flattens them into a single directory keyed by
649
+ # artifact-name (= component). Aggregate reads this directory
650
+ # to build per-wave verdicts without relying on
651
+ # `needs.wave-N.outputs.test-conclusion` (which is racy under
652
+ # matrix fan-out — last write wins).
653
+ - name: Download test-conclusion artifacts
654
+ id: download-conclusions
655
+ continue-on-error: true
656
+ uses: actions/download-artifact@v8
657
+ with:
658
+ pattern: e2e-test-conclusion-*-pr${{ github.event.issue.number || github.run_id }}
659
+ path: /tmp/test-conclusions
660
+
661
+ - name: Aggregate outcomes
662
+ id: aggregate
663
+ # `continue-on-error: true` so a failed aggregation (exit 1 on
664
+ # any wave failure) doesn't shortcut the notify-outcome step
665
+ # below. The Enforce step at the end re-propagates failure to
666
+ # the workflow conclusion.
667
+ continue-on-error: true
668
+ env:
669
+ PLAN_RESULT: ${{ needs.plan.result }}
670
+ # BUILD_STATUS is the AND of the two stack-scoped builds,
671
+ # but a build that wasn't requested (its stack filtered out
672
+ # by /run-e2e <stack>) counts as success — its job is
673
+ # `skipped`, not `failure`. The contains() checks gate each
674
+ # build's contribution by whether its stack-target appears
675
+ # in the wave-plans output.
676
+ BUILD_STATUS: ${{ ((!contains(needs.plan.outputs.stack-config, '"target":"dev-env"') || needs.build.outputs.build-status == 'success') && (!contains(needs.plan.outputs.stack-config, '"target":"studio"') || needs.build-studio.outputs.build-status == 'success')) && 'success' || 'failure' }}
677
+ BUILD_RESULT: ${{ format('dev-env={0}, studio={1}', needs.build.result, needs.build-studio.result) }}
678
+ # Classifier-emitted stage + detail surfaced in the build row's
679
+ # Notes column when BUILD_STATUS != success. Empty when build
680
+ # was a cache-hit (no execute job ran) — the aggregate script
681
+ # falls back to the legacy verdict-word render.
682
+ BUILD_STAGE: ${{ (contains(needs.plan.outputs.stack-config, '"target":"studio"') && needs.build-studio.outputs.build-status != 'success') && 'build:studio' || needs.build.outputs.build-stage || '' }}
683
+ BUILD_DETAIL: ${{ (contains(needs.plan.outputs.stack-config, '"target":"studio"') && needs.build-studio.outputs.build-status != 'success') && 'Studio build failed' || needs.build.outputs.build-detail || '' }}
684
+ WAVE_1_RESULT: ${{ needs.wave-1.result }}
685
+ WAVE_1_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-1']) }}
686
+ WAVE_2_RESULT: ${{ needs.wave-2.result }}
687
+ WAVE_2_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-2']) }}
688
+ WAVE_3_RESULT: ${{ needs.wave-3.result }}
689
+ WAVE_3_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-3']) }}
690
+ WAVE_4_RESULT: ${{ needs.wave-4.result }}
691
+ WAVE_4_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-4']) }}
692
+ # Per-(component, stack-target) verdict directory (one file
693
+ # per artifact, name == component, content ∈ {success,
694
+ # failure, skipped}). Replaces the legacy single-value
695
+ # WAVE_N_TEST_CONCLUSION env which collapses under matrix
696
+ # fan-out (last write wins) — see Download test-conclusion
697
+ # artifacts step above. PR_NUMBER lets the script construct
698
+ # the exact artifact path; on workflow_dispatch (no PR) the
699
+ # run_id stands in, matching the wave jobs' pr-number input.
700
+ CONCLUSIONS_DIR: /tmp/test-conclusions
701
+ PR_NUMBER: ${{ github.event.issue.number || github.run_id }}
702
+ run: ./taskfiles/runner/scripts/aggregate-wave-outcomes.sh
703
+
704
+ # Final notify-outcome — flips 👀 → 🚀 (overall pass) or 👎
705
+ # (any wave failed), completes the "E2E Tests" check-run, and
706
+ # (with E2E_REPORT_SKIP_RESULT="true" suppressing the compact
707
+ # banner) skips posting a duplicate PR comment. Per-component
708
+ # PR comments were already posted by each wave's e2e-report.sh.
709
+ # Gated on check-run-id presence so workflow_dispatch (no
710
+ # acknowledge → no check-run) skips silently.
711
+ #
712
+ # Also skip when plan failed: the planner's own "Notify resolve
713
+ # failure" step already posted the structured error message
714
+ # (e.g. "Malformed Depends-On line: ...") and updated the
715
+ # check-run + reaction. The aggregate's failed-pipeline banner
716
+ # here would be a half-empty table that just says "plan failed"
717
+ # — no extra signal, and it buries the specific error message
718
+ # under a generic-looking second comment.
719
+ - name: Notify final outcome
720
+ if: always() && needs.acknowledge.outputs.check-run-id != '' && needs.plan.result != 'failure'
721
+ continue-on-error: true
722
+ uses: genlayerlabs/genlayer-e2e/.github/actions/notify-outcome@main
723
+ with:
724
+ outcome: ${{ steps.aggregate.outcome == 'success' && 'success' || 'failure' }}
725
+ check-run-title: 'E2E Tests'
726
+ repo: ${{ github.repository }}
727
+ comment-id: ${{ github.event.comment.id }}
728
+ check-run-id: ${{ needs.acknowledge.outputs.check-run-id }}
729
+ pr-number: ${{ github.event.issue.number }}
730
+ github-token: ${{ steps.app-token.outputs.token }}
731
+
732
+ # Re-propagate the aggregate's exit status to the workflow
733
+ # conclusion. Without this, the result job would always succeed
734
+ # (because aggregate has `continue-on-error: true`) and the
735
+ # whole pipeline would render as green even on real failures.
736
+ - name: Enforce overall outcome
737
+ if: steps.aggregate.outcome == 'failure'
738
+ run: |
739
+ echo "::error::Pipeline failed — see ::error:: lines in Aggregate outcomes step"
740
+ exit 1