From 50079ea70a6ba65df9183136e43927e20e1227d0 Mon Sep 17 00:00:00 2001 From: "ci-core-e2e-runner[bot]" <263344042+ci-core-e2e-runner[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 19:15:02 +0000 Subject: [PATCH] ci(workflows): sync e2e.yml from genlayer-e2e --- .github/workflows/e2e.yml | 732 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..efc2849 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,732 @@ +# Source-of-truth E2E pipeline workflow. +# +# This file is BOTH: +# - The workflow that runs in this repo (genlayer-e2e) for dispatch / +# local testing. +# - The file synced verbatim to every consumer repo as +# .github/workflows/e2e.yml (sync-template.yaml owns the fan-out). +# +# Consumers don't carry a separate thin caller — keeping the consumer's +# workflow file BYTE-IDENTICAL to this one means: +# - Sidebar tiles stay flat: `acknowledge / register`, `plan / action`, +# `build / discover`, `genlayer-core / shard ... / e2e`, `result` — +# no wrapper-job prefix. A consumer-side thin caller would force +# ` /` on every inner tile (see #386 follow-up where +# deleting e2e-harness.yml restored the flat layout). +# - Behavior changes ship via one sync PR per consumer, instead of +# each consumer hand-editing their wrapper. +# +# Two trigger paths: +# - `issue_comment` — PR comment `/run-e2e [profile] [track] [scope]` +# on the consumer's repo. The acknowledge job's `if:` gates on +# author-association so only members/owners/collaborators can fire +# the pipeline. +# - `workflow_dispatch` — manual / debug in this repo (UI dropdowns). +# Acknowledge is skipped (no PR comment context); plan/build/waves +# run with workflow_dispatch input values. +# +# All internal `uses:` references are cross-repo +# (`genlayerlabs/genlayer-e2e/.github/workflows/X.yml@main`) so the same +# file works whether it lives in this repo or in a synced consumer. +# Feature-branch testing requires sedding `@main` → `@` on the +# inner uses; see feedback_branch_pin_for_testing.md. +# +# Sync-time hack — `on: issue_comment:` injection +# ------------------------------------------------ +# The source-of-truth file in genlayer-e2e does NOT declare an +# `on: issue_comment:` trigger. If it did, this workflow would fire on +# EVERY comment in genlayer-e2e's own PRs (coderabbit, dependabot, …) +# and consume a 3-second skipped "noop" run per comment — visible +# clutter on the Actions list. +# +# `sync-templates.sh` injects the `issue_comment:` trigger block at +# sync time at the `# SYNC_INJECT(issue_comment)` marker below. The +# synced consumer copy gets the trigger; genlayer-e2e's source never +# does. Manual debug here still works via the `workflow_dispatch:` +# trigger which IS declared. +# +# The runtime expressions below (run-name, concurrency, acknowledge's +# `if:`, build/wave `pr-number` / `comment-id` coalesces) all reference +# `github.event_name == 'issue_comment'` and `github.event.comment.*` +# — those evaluate cleanly to false / empty on the workflow_dispatch +# path in genlayer-e2e, so they stay unconditional and work for both +# consumer and source contexts. Only the trigger declaration itself +# is injected. + +name: E2E Pipeline + +# Tag every run with a descriptive title: +# issue_comment /run-e2e: PR # /run-e2e +# workflow_dispatch: E2E Test // +# anything else: noop +# +# Manual dispatch is only available on the source repo (genlayer-e2e +# itself, never on a synced consumer), and is intentionally unrestricted +# — there's no per-actor concurrency group, so a developer can fire +# several test runs side-by-side. Putting profile/track/scope in the +# title makes those runs distinguishable at a glance on the Actions +# list (where the bare run_id was previously opaque). +run-name: >- + ${{ (github.event_name == 'issue_comment' + && startsWith(github.event.comment.body, '/run-e2e') + && format('PR #{0} /run-e2e', github.event.issue.number)) + || (github.event_name == 'workflow_dispatch' + && format('E2E Test {0}/{1}/{2}/{3}', inputs.profile, inputs.track, inputs.scope, inputs.stack)) + || format('noop {0}', github.run_id) }} + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + profile: + description: Profile name (must be a top-level key in profiles.json) + required: false + default: default + type: choice + options: + - default + - testnet + track: + description: Matrix track (must exist as matrix/.yaml) + required: false + default: v0.5 + type: choice + options: + - v0.5 + scope: + description: > + Wave-plan scope filter. `all` runs every wave (default); + `core` runs only wave-1 (genlayer-node / test-core); + `tooling` runs waves 2-4 (SDKs + explorer + testing-suite + wallet). + Out-of-scope waves cascade-skip with reason "scope". + required: false + default: all + type: choice + options: + - all + - core + - tooling + stack: + description: > + Stack-target filter. `all` exercises every declared stack + (default); `dev-env` runs only the genlayer-dev-env variants; + `studio` runs only the GenLayer Studio variants. Components + whose declared `stack-targets` don't include the chosen + stack cascade-skip with reason "stack". + required: false + default: all # SYNC_INJECT(default_stack) + type: choice + options: + - all + - dev-env + - studio + +permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write + +# /run-e2e triggers share a per-PR group so a new comment cancels the +# previous in-progress run. Other issue_comment events (and +# workflow_dispatch) get a unique group so they never queue behind a +# hung /run-e2e. +concurrency: + group: >- + ${{ (github.event_name == 'issue_comment' + && startsWith(github.event.comment.body, '/run-e2e')) + && format('e2e-{0}-pr-{1}', github.repository, github.event.issue.number) + || format('e2e-noop-{0}', github.run_id) }} + cancel-in-progress: true + +env: + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID || 'devops-infra-428314' }} + GCP_WIF_PROVIDER: ${{ vars.GCP_WIF_PROVIDER || 'projects/795309574007/locations/global/workloadIdentityPools/ci-core-e2e-runner/providers/ci-core-e2e-runner' }} + GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT || 'ci-core-e2e-runner@devops-workload-identities.iam.gserviceaccount.com' }} + GCP_SECRET_APP_CLIENT_ID: ${{ vars.GCP_SECRET_APP_CLIENT_ID || 'ci-core-e2e-runner-app-client-id' }} + GCP_SECRET_APP_PRIVATE_KEY: ${{ vars.GCP_SECRET_APP_PRIVATE_KEY || 'ci-core-e2e-runner-app-private-key' }} + # Suppress notify-outcome's compact PR banner. Detailed results + # table still posted. Remove to re-enable. + E2E_REPORT_SKIP_RESULT: "true" + +jobs: + # =========================================================================== + # acknowledge — PR-side bookkeeping and comment tokenization. + # + # The `if:` guard lives here (not inside e2e-acknowledge.yml) because + # only the caller can guard on `github.event_name` + comment author + # association before the reusable workflow is even resolved. On + # workflow_dispatch, `github.event.issue` is null → guard false → + # acknowledge is skipped (and the plan job's `if:` opts back in for + # the dispatch path). + # =========================================================================== + acknowledge: + # PR-state guard: refuse /run-e2e on closed / merged PRs. After a + # PR merges, its head branch is typically deleted, which breaks + # every "content at PR head" fetch (third_party version files, + # target-repo matrix.yaml) — the resolver silently falls through + # to the baseline matrix and downstream cache keys drift off + # whatever the baseline's branch tip happens to be at that moment. + # If you actually need to retest, re-run on main or open a fresh + # PR with the same content. + if: >- + github.event.issue.pull_request + && github.event.issue.state == 'open' + && startsWith(github.event.comment.body, '/run-e2e') + && contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-acknowledge.yml@main + with: + comment-id: ${{ github.event.comment.id }} + comment-body: ${{ github.event.comment.body }} + issue-number: ${{ github.event.issue.number }} + target-repo: ${{ github.repository }} + server-url: ${{ github.server_url }} + run-id: ${{ github.run_id }} + secrets: inherit + + # =========================================================================== + # plan — pin profile / track / matrix-refs / wave-plans / cache-key. + # Dual-path internally (PR vs dispatch); see e2e-planner.yml. + # + # `if:` opts back in on workflow_dispatch where acknowledge is + # intentionally skipped. PR-comment path with an unauthorized author + # (acknowledge skipped because guard failed) does NOT re-enter here — + # the `github.event_name == 'workflow_dispatch'` clause filters it out. + # =========================================================================== + plan: + needs: acknowledge + if: | + !cancelled() && + (needs.acknowledge.result == 'success' || + (needs.acknowledge.result == 'skipped' && github.event_name == 'workflow_dispatch')) + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-planner.yml@main + with: + # Coalesce: acknowledge tokens win on the PR-comment path; on + # workflow_dispatch the tokens are empty and we fall back to + # the manual choice inputs. + profile: ${{ needs.acknowledge.outputs.profile-token || inputs.profile }} + track: ${{ needs.acknowledge.outputs.track-token || inputs.track }} + scope: ${{ needs.acknowledge.outputs.scope-token || inputs.scope }} + # SYNC_INJECT(default_stack_fallback) rewrites the literal 'all' on + # consumer copies (e.g. 'dev-env' for genlayer-node), so an + # issue_comment `/run-e2e` with no stack token still routes to + # the per-consumer default — inputs.stack is workflow_dispatch- + # only and resolves empty on the issue_comment path. + stack: ${{ needs.acknowledge.outputs.stack-token || inputs.stack || 'all' }} # SYNC_INJECT(default_stack_fallback) + target-repo: ${{ github.repository }} + pr-number: ${{ github.event.issue.number || '' }} + comment-id: ${{ github.event.comment.id || '' }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + # acknowledge picks the layered-cache cleavage based on the + # consumer repo. On workflow_dispatch acknowledge is skipped + # and its output is empty — the planner's empty default then + # disables layering (today's pre-Slice-A behaviour). + pre-build-cache: ${{ needs.acknowledge.outputs.pre-build-cache || '' }} + + # =========================================================================== + # build — full stack up + pack to cache. Synthetic PR context on the + # dispatch path: run_id stands in for pr-number; comment-id / + # check-run-id stay empty so downstream PR-facing steps are no-ops. + # =========================================================================== + build: + name: build (dev-env) + needs: [acknowledge, plan] + # Without an explicit `if:`, GHA's implicit `success()` would require + # acknowledge to have succeeded — but acknowledge is intentionally + # skipped on the workflow_dispatch path. Mirror the wave jobs and + # gate only on plan succeeding. + # + # Skip when the stack filter omits dev-env. `/run-e2e studio` makes + # every wave row's stack-target='studio' (build-studio handles + # those), so the dev-env bundle isn't needed. Mirrors build-studio's + # inverse gate below. + if: | + !cancelled() && + needs.plan.result == 'success' && + contains(needs.plan.outputs.stack-config, '"target":"dev-env"') + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build.yml@main + with: + track: ${{ github.ref_name }} + profile: ${{ needs.plan.outputs.profile }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + consensus-sha: ${{ needs.plan.outputs.consensus-sha }} + genlayer-node-sha: ${{ needs.plan.outputs.genlayer-node-sha }} + resolved-shas-json: ${{ needs.plan.outputs.resolved-shas-json }} + build-cache-key: ${{ needs.plan.outputs.build-cache-key }} + full-cache-key: ${{ needs.plan.outputs.full-cache-key }} + pre-build-cache: ${{ needs.plan.outputs.pre-build-cache }} + # Mirrors matrix/.yaml shape ({core, harness, tooling}). The + # conclusion job's Emit build summary step parses this with jq + # to render the full component Refs sub-list in the Execution + # block. Trailing JSON commas would be invalid — keep the same + # field set as the matrix file. + matrix-json: >- + {"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 }}"}} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} + + # =========================================================================== + # build-studio — bring up a Studio stack and pack it for Studio-target shards. + # Runs in parallel with the dev-env build. Devenv-target wave rows continue + # to consume the existing full-cache-key; Studio-target rows restore the + # per-run Studio bundle produced here. + # =========================================================================== + build-studio: + name: build (studio) + needs: [acknowledge, plan] + if: | + !cancelled() && + needs.plan.result == 'success' && + contains(needs.plan.outputs.stack-config, '"target":"studio"') + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-build-studio.yml@main + with: + track: ${{ github.ref_name }} + profile: ${{ needs.plan.outputs.profile }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + + # =========================================================================== + # Wave jobs — cascade pattern with sentinel-as-skip. Each wave matrix + # comes from `needs.plan.outputs.wave-plans` (one JSON object folding + # every per-wave plan array). + # =========================================================================== + wave-1: + # `name:` overrides GHA's default `wave-1 (component, test-task, …)` + # matrix-tuple display. Reads matrix.job-name (resolved from + # components.yaml's `job-name` field, falling back to the + # component key) so the listing reads e.g. `genlayer-core / shard + # (shard-1, true) / e2e`. + # + # When build fails, append "(skipped - build fails)" to the tile so + # the UI attributes the skip to its root-cause layer. The + # `matrix.component != 'none'` guard prevents existing sentinel rows + # (e.g., scope-filtered or impacted-set empty waves) from being + # relabelled — those keep their baked-in "(skipped - manual)" / + # "(skipped - scope)" / "(skipped - no scenarios)" label unchanged. + # Waves 2-4 mirror this pattern with extra upstream-wave clauses + # reading `needs.wave-K.outputs.failure-label` (the per-component + # layer tag emitted by e2e-run.yml when that wave's run failed). + 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 }} + needs: [acknowledge, plan, build, build-studio] + # No `build-status == 'success'` gate — when build fails we want + # wave-1 to RUN (so the `name:` expression evaluates and tiles + # render cleanly) but no-op via the sentinel-component override + # below. Same mechanism as the existing wave-2/3/4 cascade. + # + # `!cancelled() &&` bypasses GHA's implicit needs-failure-cascade: + # when an upstream `needs:` job (here, `build`) fails, GHA defaults + # to auto-skipping downstream jobs unless their `if:` explicitly + # opts in via `always()` / `!cancelled()` / `failure()`. A skipped + # job does NOT have its `name:` expression evaluated, so without + # this prefix wave-1's tile shows the raw `${{ ... }}` text on + # build failure. Waves 2-4 already carry the same prefix. + # + # resolve-components / build-wave-plans emit a sentinel-row plan + # (component='none') when no impacted component maps to this wave, + # so the matrix always has at least one row and GHA can render + # `${{ matrix.job-name }}` for the placeholder tile (per + # actions/runner#1985, the unresolved name expression needs a + # matrix row to bind to). The sentinel's empty features-source / + # tags / test-task naturally cascade through e2e-run.yml's + # allocate → shard → conclusion chain to a no-op success — see + # resolve-components/action.yml for the full cascade explanation. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-1'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: if build did NOT produce a clean verdict, + # force the sentinel path so wave-1 no-ops cleanly. Same + # mechanism waves 2-4 use for upstream-wave failures — see + # e2e-run.yml's sentinel branches. + 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 }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} + + wave-2: + # Cascade-skip label rules (walked in order, first match wins): + # 1. build failed → "(skipped - build fails)" + # 2. wave-1's failure-label is non-empty (e.g. "core") → + # "(skipped - {label} fails)" + # 3. wave-1 failed but emitted no tag (failure-label empty) → + # generic "(skipped - fails)" + # else → matrix.job-name (real run or sentinel-baked label) + # + # `failure-label` is e2e-run.yml's per-component output, populated + # from `inputs.failure-tag` only when the run's test-conclusion is + # 'failure'. Empty otherwise — so a successful or sentinel-skipped + # wave doesn't carry a stale tag forward. + 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 }} + needs: [acknowledge, plan, build, build-studio, wave-1] + # No cascade gate — wave-2 always runs when plan is OK. If + # build or wave-1 failed, with.component is overridden to 'none' + # below, tripping e2e-run.yml's sentinel path (allocate/shard/ + # check-run/retry/cleanup-artifacts skip; conclusion emits + # 'skipped'). Same flow as manual skip:true. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-2'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1) failed. `.result == 'failure'` is the + # reliable trip signal — failure-label is only used for the + # tile label content above, not the trip decision. + 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 }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} + + wave-3: + # See wave-2 for the cascade-skip label semantics. Walk order + # (first match wins): build → wave-1 failure-label → wave-2 + # failure-label → generic-fallback. The `.result == 'failure'` + # fallback catches the rare case where an upstream wave failed + # but didn't emit a failure-tag (e.g. internal job-level error + # before conclusion ran). + 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 }} + needs: [acknowledge, plan, build, build-studio, wave-1, wave-2] + # No cascade gate — wave-3 always runs. If build or any upstream + # wave failed, with.component is overridden to 'none' below, + # tripping e2e-run.yml's sentinel path (no GCE provisioned, + # conclusion emits 'skipped'). See wave-2 for the full rationale. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-3'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1 / wave-2) failed. Uses `.result == 'failure'` + # for reliability — failure-label is only consumed for the + # tile label content above, not the trip decision. + 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 }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} + + wave-4: + # See wave-2 for the cascade-skip label semantics. Walk order + # (first match wins): build → wave-1 → wave-2 → wave-3 failure- + # label, then generic-fallback via `.result == 'failure'`. + 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 }} + needs: [acknowledge, plan, build, build-studio, wave-1, wave-2, wave-3] + # No cascade gate — wave-4 always runs. See wave-2 for the + # cascade-as-sentinel rationale. + if: | + !cancelled() && + needs.plan.result == 'success' + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.wave-plans)['wave-4'] }} + uses: genlayerlabs/genlayer-e2e/.github/workflows/e2e-run.yml@main + with: + # Cascade override: force sentinel path when any upstream layer + # (build / wave-1 / wave-2 / wave-3) failed. + 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 }} + track: ${{ github.ref_name }} + job-name: ${{ matrix.job-name || matrix.component }} + stack-target: ${{ matrix.stack-target || 'dev-env' }} + setup-task: ${{ matrix.setup-task }} + test-task: ${{ matrix.test-task }} + tags: ${{ matrix.tags }} + split: ${{ matrix.split }} + features-source: ${{ matrix.features-source }} + retry: ${{ matrix.retry }} + max-shard-split: ${{ matrix.max-shard-split || 0 }} + failure-tag: ${{ matrix.failure-tag || '' }} + genvm-version: ${{ needs.plan.outputs.genvm-version }} + genlayer-node-ref: ${{ needs.plan.outputs.genlayer-node-ref }} + consensus-ref: ${{ needs.plan.outputs.consensus-ref }} + genlayer-js-ref: ${{ needs.plan.outputs.genlayer-js-ref }} + genlayer-py-ref: ${{ needs.plan.outputs.genlayer-py-ref }} + genlayer-cli-ref: ${{ needs.plan.outputs.genlayer-cli-ref }} + genlayer-studio-ref: ${{ needs.plan.outputs.genlayer-studio-ref }} + genlayer-explorer-ref: ${{ needs.plan.outputs.genlayer-explorer-ref }} + genlayer-testing-suite-ref: ${{ needs.plan.outputs.genlayer-testing-suite-ref }} + genvm-linter-ref: ${{ needs.plan.outputs.genvm-linter-ref }} + genlayer-wallet-ref: ${{ needs.plan.outputs.genlayer-wallet-ref }} + harness-ref: ${{ needs.plan.outputs.harness-ref }} + harness-sha: ${{ needs.plan.outputs.harness-sha }} + cache-key: ${{ needs.plan.outputs.full-cache-key }} + studio-cache-key: ${{ needs.plan.outputs.studio-cache-key }} + profile: ${{ needs.plan.outputs.profile }} + pr-number: ${{ github.event.issue.number || github.run_id }} + comment-id: ${{ github.event.comment.id || '' }} + target-repo: ${{ github.repository }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id || '' }} + head-sha: ${{ needs.acknowledge.outputs.head-sha || github.sha }} + github-retry-max: ${{ needs.plan.outputs.github-retry-max }} + github-retry-initial-delay: ${{ needs.plan.outputs.github-retry-initial-delay }} + github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} + + # =========================================================================== + # result — final guard that makes the workflow's conclusion reflect + # the REAL outcome. Mirrors `plan` at the start (plan → build → + # waves → result). + # + # Per-wave verdict comes from each wave's `test-conclusion` output + # (sourced from e2e-run.yml's `conclusion` job), which reflects retry + # recovery — so a first-try shard failure that retry recovered + # counts as 'success'. When test-conclusion is empty (wave was + # skipped or never reached the conclusion job), fall back to GHA's + # `.result` for the rough verdict. + # =========================================================================== + result: + needs: [acknowledge, plan, build, build-studio, wave-1, wave-2, wave-3, wave-4] + # `always() && plan.result != 'skipped'` rather than bare `always()`: + # noop issue_comments (any non-/run-e2e comment in this repo, since + # the workflow lives at `on: issue_comment`) skip acknowledge → plan + # → build → waves all the way down. Without this guard, result still + # runs, and its env block resolves + # `toJson(fromJson(needs.plan.outputs.wave-plans)['wave-N'])` against + # plan's empty output → fromJson('') fails the template at + # job-start (see run 26223668789). Letting result skip on + # plan==skipped keeps the noop pipeline clean — `acknowledge / plan / + # build / waves / result` all skipped, no red marker. + if: always() && needs.plan.result != 'skipped' + runs-on: ubuntu-latest + steps: + # App token for the final notify-outcome — completing the + # check-run + swapping the 👀 reaction requires the App token + # (consumer's GITHUB_TOKEN can't update a check-run the App + # created). Cross-repo `uses:` because no checkout has run yet. + - name: Generate GitHub App token + id: app-token + uses: genlayerlabs/genlayer-e2e/.github/actions/gcp-app-token@main + + # App token, not the default github.token: when this workflow is + # synced to a consumer, github.token is the CONSUMER's repo-scoped + # token and can't read the private genlayer-e2e repo (exit 128 + # auth failure on the actions/checkout step — observed on + # genlayer-node run 26240246363). + - uses: actions/checkout@v6 + with: + repository: genlayerlabs/genlayer-e2e + ref: main + token: ${{ steps.app-token.outputs.token }} + + # Per-(component, stack-target) verdict artifacts uploaded by + # each e2e-run.yml conclusion job. One artifact per matrix row + # — pattern flattens them into a single directory keyed by + # artifact-name (= component). Aggregate reads this directory + # to build per-wave verdicts without relying on + # `needs.wave-N.outputs.test-conclusion` (which is racy under + # matrix fan-out — last write wins). + - name: Download test-conclusion artifacts + id: download-conclusions + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: e2e-test-conclusion-*-pr${{ github.event.issue.number || github.run_id }} + path: /tmp/test-conclusions + + - name: Aggregate outcomes + id: aggregate + # `continue-on-error: true` so a failed aggregation (exit 1 on + # any wave failure) doesn't shortcut the notify-outcome step + # below. The Enforce step at the end re-propagates failure to + # the workflow conclusion. + continue-on-error: true + env: + PLAN_RESULT: ${{ needs.plan.result }} + # BUILD_STATUS is the AND of the two stack-scoped builds, + # but a build that wasn't requested (its stack filtered out + # by /run-e2e ) counts as success — its job is + # `skipped`, not `failure`. The contains() checks gate each + # build's contribution by whether its stack-target appears + # in the wave-plans output. + 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' }} + BUILD_RESULT: ${{ format('dev-env={0}, studio={1}', needs.build.result, needs.build-studio.result) }} + # Classifier-emitted stage + detail surfaced in the build row's + # Notes column when BUILD_STATUS != success. Empty when build + # was a cache-hit (no execute job ran) — the aggregate script + # falls back to the legacy verdict-word render. + BUILD_STAGE: ${{ (contains(needs.plan.outputs.stack-config, '"target":"studio"') && needs.build-studio.outputs.build-status != 'success') && 'build:studio' || needs.build.outputs.build-stage || '' }} + 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 || '' }} + WAVE_1_RESULT: ${{ needs.wave-1.result }} + WAVE_1_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-1']) }} + WAVE_2_RESULT: ${{ needs.wave-2.result }} + WAVE_2_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-2']) }} + WAVE_3_RESULT: ${{ needs.wave-3.result }} + WAVE_3_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-3']) }} + WAVE_4_RESULT: ${{ needs.wave-4.result }} + WAVE_4_PLAN: ${{ toJson(fromJson(needs.plan.outputs.wave-plans)['wave-4']) }} + # Per-(component, stack-target) verdict directory (one file + # per artifact, name == component, content ∈ {success, + # failure, skipped}). Replaces the legacy single-value + # WAVE_N_TEST_CONCLUSION env which collapses under matrix + # fan-out (last write wins) — see Download test-conclusion + # artifacts step above. PR_NUMBER lets the script construct + # the exact artifact path; on workflow_dispatch (no PR) the + # run_id stands in, matching the wave jobs' pr-number input. + CONCLUSIONS_DIR: /tmp/test-conclusions + PR_NUMBER: ${{ github.event.issue.number || github.run_id }} + run: ./taskfiles/runner/scripts/aggregate-wave-outcomes.sh + + # Final notify-outcome — flips 👀 → 🚀 (overall pass) or 👎 + # (any wave failed), completes the "E2E Tests" check-run, and + # (with E2E_REPORT_SKIP_RESULT="true" suppressing the compact + # banner) skips posting a duplicate PR comment. Per-component + # PR comments were already posted by each wave's e2e-report.sh. + # Gated on check-run-id presence so workflow_dispatch (no + # acknowledge → no check-run) skips silently. + # + # Also skip when plan failed: the planner's own "Notify resolve + # failure" step already posted the structured error message + # (e.g. "Malformed Depends-On line: ...") and updated the + # check-run + reaction. The aggregate's failed-pipeline banner + # here would be a half-empty table that just says "plan failed" + # — no extra signal, and it buries the specific error message + # under a generic-looking second comment. + - name: Notify final outcome + if: always() && needs.acknowledge.outputs.check-run-id != '' && needs.plan.result != 'failure' + continue-on-error: true + uses: genlayerlabs/genlayer-e2e/.github/actions/notify-outcome@main + with: + outcome: ${{ steps.aggregate.outcome == 'success' && 'success' || 'failure' }} + check-run-title: 'E2E Tests' + repo: ${{ github.repository }} + comment-id: ${{ github.event.comment.id }} + check-run-id: ${{ needs.acknowledge.outputs.check-run-id }} + pr-number: ${{ github.event.issue.number }} + github-token: ${{ steps.app-token.outputs.token }} + + # Re-propagate the aggregate's exit status to the workflow + # conclusion. Without this, the result job would always succeed + # (because aggregate has `continue-on-error: true`) and the + # whole pipeline would render as green even on real failures. + - name: Enforce overall outcome + if: steps.aggregate.outcome == 'failure' + run: | + echo "::error::Pipeline failed — see ::error:: lines in Aggregate outcomes step" + exit 1