Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ on:
image_tag:
description: GHCR image tag for this trusted run
required: true
schedule:
# Hourly flake gap-filler. Every tick runs only the lightweight schedule-gate
# job; the expensive setup + e2e matrix run only when the gate returns
# proceed=true (stale main AND idle e2e AND toggle enabled). See schedule-gate
# below and malbeclabs/infra docs/plans/2026-07-02-e2e-flake-gap-filler-*.
- cron: '0 * * * *'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
Expand All @@ -38,7 +44,80 @@ env:
DZ_VALIDATOR_METADATA_SERVICE_MOCK_IMAGE: ghcr.io/malbeclabs/dz-e2e/validator-metadata-service-mock:${{ github.event.inputs.image_tag || github.sha }}

jobs:
schedule-gate:
# Flake gap-filler gate. Runs only on the hourly schedule, on a GitHub-hosted
# runner so the hourly no-op never consumes a self-hosted e2e slot. Emits
# outputs.proceed='true' only when the gap-filler is enabled AND main is stale
# AND no e2e run is in flight. setup (and thus the matrix) stays gated on this
# for schedule events. Cheap: a few API reads.
name: Flake gap-filler gate
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
proceed: ${{ steps.gate.outputs.proceed }}
env:
# Default-off master switch. Set repo variable E2E_GAP_FILLER_ENABLED='true'
# to activate (Settings -> Secrets and variables -> Actions -> Variables).
GAP_FILLER_ENABLED: ${{ vars.E2E_GAP_FILLER_ENABLED }}
steps:
- name: Decide whether to run the gap-filler
id: gate
uses: actions/github-script@v7
with:
script: |
const STALE_HOURS = 6;
const enabled = process.env.GAP_FILLER_ENABLED === 'true';
const selfId = context.runId;
const { owner, repo } = context.repo;

// Stale check: newest e2e run against main HEAD (push or schedule),
// excluding this run. Missing => Infinity => stale.
const mainRuns = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: 'e2e.yml', branch: 'main', per_page: 30,
});
const newestMain = mainRuns.data.workflow_runs
.filter(r => r.id !== selfId)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
const ageHours = newestMain
? (Date.now() - new Date(newestMain.created_at).getTime()) / 3.6e6
: Infinity;
const stale = ageHours >= STALE_HOURS;

// Idle check: any e2e run not yet completed, on ANY branch, excluding
// this run. These GitHub run statuses precede 'completed'.
const active = [];
for (const status of ['queued', 'in_progress', 'requested', 'waiting', 'pending']) {
const res = await github.rest.actions.listWorkflowRuns({
owner, repo, workflow_id: 'e2e.yml', status, per_page: 100,
});
active.push(...res.data.workflow_runs.filter(r => r.id !== selfId));
}
const idle = active.length === 0;

const proceed = enabled && stale && idle;
const ageStr = Number.isFinite(ageHours) ? ageHours.toFixed(1) + 'h' : 'none';
core.info(`enabled=${enabled} stale=${stale} (newest main age=${ageStr}, threshold=${STALE_HOURS}h) idle=${idle} (active=${active.length}) => proceed=${proceed}`);
core.setOutput('proceed', String(proceed));
await core.summary.addRaw(
`### e2e flake gap-filler gate\n` +
`- enabled: **${enabled}**\n` +
`- stale: **${stale}** (newest main run age ${ageStr} / ${STALE_HOURS}h)\n` +
`- idle: **${idle}** (in-flight e2e runs excluding self: ${active.length})\n` +
`- **proceed: ${proceed}**\n`
).write();

setup:
needs: [schedule-gate]
# Non-schedule events (push/PR/dispatch) run setup as before. Schedule events
# run it only when the gap-filler gate passed. always() is required because
# schedule-gate is skipped for non-schedule events, and a skipped need would
# otherwise skip setup too.
if: >-
${{ always() &&
(github.event_name != 'schedule' ||
needs.schedule-gate.outputs.proceed == 'true') }}
runs-on: self-hosted
permissions:
packages: write
Expand Down
Loading