Route the exercised subprocess and provider-migration dependencies to both real-update journeys. Keep backup routing under an explicit dispatch test. Use the aggregate's existing detect outputs to reject skipped or missing update consumers while retaining legitimate unselected PR skips. Verified with the real classifier and aggregate subprocesses against b9dec76a042, composite ff5c8f1a64b, and fixed code. Canonical runner: 571 passed, 0 failed, 1 native-Windows skip across CI and marker corpora. (cherry picked from commit a082ca3c62ed49382f173a07882d4280d0ba7c9c) (cherry picked from commit ed9f2028256b8b1d6af81ef97cd472f30d2cb042) (cherry picked from commit 6d5cc740b6a75d476802222c2f4754855a5712b0)
485 lines
22 KiB
YAML
485 lines
22 KiB
YAML
name: CI
|
|
|
|
# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
|
|
# calls the sub-workflows that a PR can actually affect. A final
|
|
# ``all-checks-pass`` gate job aggregates results so branch protection only
|
|
# needs to require a single check.
|
|
#
|
|
# Sub-workflows are triggered via ``workflow_call`` and keep their own job
|
|
# definitions, matrices, and concurrency settings. They no longer have
|
|
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
|
# through this file.
|
|
#
|
|
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
|
|
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
|
|
# main-only automation uses protected environments in its own workflows.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
release:
|
|
description: 'Stable-release candidate run: force every applicability lane and make the aggregate gate strict (skipped required lanes fail).'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
strict_acceptance:
|
|
description: "Acceptance runs only: known_failure owners whose gaps FAIL this run instead of xfailing ('upd-txn', a comma list, or '1' for every gap). Sets HERMES_E2E_STRICT_ACCEPTANCE for the E2E suites (tests/e2e/core/_pending_fixes.py)."
|
|
required: false
|
|
type: string
|
|
default: ''
|
|
workflow_call:
|
|
inputs:
|
|
release:
|
|
description: 'Stable-release candidate run: force every applicability lane and make the aggregate gate strict (skipped required lanes fail).'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
pull_request:
|
|
push:
|
|
branches: [main]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
|
|
|
|
# cancel-in-progress only ever applies to PR events. A push, a dispatch, and
|
|
# above all a stable-release workflow_call run are never cancelled by a later
|
|
# commit or a rerun of the same branch — the caller's own concurrency uses
|
|
# github.run_id, so even a parent rerun cannot kill this child mid-flight.
|
|
# Release (workflow_call with inputs.release) never cancels and never gets
|
|
# cancelled: its group is github.run_id. PRs collapse per-PR; pushes use the
|
|
# ref as before.
|
|
concurrency:
|
|
group: ci-${{ inputs.release == true && github.run_id || (github.event_name == 'pull_request' && github.event.pull_request.number || github.ref) }}
|
|
cancel-in-progress: ${{ inputs.release != true && github.event_name == 'pull_request' }}
|
|
|
|
jobs:
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# detect: run the classifier once. Every downstream job reads its outputs
|
|
# to decide whether to run. On push/dispatch the classifier fails open
|
|
# (all lanes true) so post-merge validation is never weakened.
|
|
#
|
|
# A release run (inputs.release) additionally forces every lane true via
|
|
# the `release-forced-*` outputs: a stable candidate must run the FULL
|
|
# pipeline, not the lanes its diff would touch — the diff of a release
|
|
# tag is not a meaningful applicability signal.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
detect:
|
|
name: Detect affected areas
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 1
|
|
outputs:
|
|
python: ${{ steps.gate-lanes.outputs.python }}
|
|
python_prod: ${{ steps.gate-lanes.outputs.python_prod }}
|
|
frontend: ${{ steps.gate-lanes.outputs.frontend }}
|
|
site: ${{ steps.gate-lanes.outputs.site }}
|
|
scan: ${{ steps.gate-lanes.outputs.scan }}
|
|
deps: ${{ steps.gate-lanes.outputs.deps }}
|
|
uv_lock: ${{ steps.gate-lanes.outputs.uv_lock }}
|
|
npm_lock: ${{ steps.gate-lanes.outputs.npm_lock }}
|
|
bootstrap: ${{ steps.gate-lanes.outputs.bootstrap }}
|
|
desktop_updater: ${{ steps.gate-lanes.outputs.desktop_updater }}
|
|
rust: ${{ steps.gate-lanes.outputs.rust }}
|
|
docker_meta: ${{ steps.gate-lanes.outputs.docker_meta }}
|
|
e2e: ${{ steps.gate-lanes.outputs.e2e }}
|
|
e2e_upgrade: ${{ steps.gate-lanes.outputs.e2e_upgrade }}
|
|
e2e_desktop_core: ${{ steps.gate-lanes.outputs.e2e_desktop_core }}
|
|
e2e_desktop_update: ${{ steps.gate-lanes.outputs.e2e_desktop_update }}
|
|
event_name: ${{ github.event_name }}
|
|
steps:
|
|
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
|
|
- name: Detect affected areas
|
|
id: classify
|
|
uses: ./.github/actions/detect-changes
|
|
with:
|
|
github-token: ${{ github.token }}
|
|
- name: Force all lanes on release
|
|
# Overwrite the classifier outputs with 'true' when inputs.release.
|
|
id: gate-lanes
|
|
env:
|
|
RELEASE: ${{ inputs.release }}
|
|
CLASSIFIED: ${{ toJSON(steps.classify.outputs) }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json, os
|
|
values = json.loads(os.environ['CLASSIFIED'])
|
|
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
|
|
for lane, value in values.items():
|
|
if os.environ['RELEASE'] == 'true' and value in ('true', 'false'):
|
|
value = 'true'
|
|
if '\n' in value or '\r' in value:
|
|
continue
|
|
output.write(f'{lane}={value}\n')
|
|
PY
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
|
# Skipped workflows (if condition is false) don't spin up runners.
|
|
#
|
|
# The E2E suites (e2e, e2e-upgrade, both Desktop suites, and the two
|
|
# Windows journeys in tests-os) take most of the larger-runner minutes.
|
|
# On a pull request each runs only when the diff touches that suite or the
|
|
# code it guards, or the PR carries the `run-e2e` label
|
|
# (scripts/ci/classify_changes.py::_E2E_LANES). Every push to main runs
|
|
# all of them, so a regression the path lists miss turns main red after
|
|
# the merge.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
tests:
|
|
name: Python tests
|
|
needs: detect
|
|
# Its E2E jobs are lanes of their own: a lane the classifier sets must reach
|
|
# its job whatever `python` says (tests/ci/test_update_ci_routing.py).
|
|
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.e2e == 'true' || needs.detect.outputs.e2e_upgrade == 'true'
|
|
uses: ./.github/workflows/tests.yml
|
|
with:
|
|
e2e: ${{ needs.detect.outputs.e2e == 'true' }}
|
|
e2e_upgrade: ${{ needs.detect.outputs.e2e_upgrade == 'true' }}
|
|
strict_acceptance: ${{ inputs.strict_acceptance || '' }}
|
|
|
|
# macOS + Windows lanes. The main `tests` lane above is Linux-only, and
|
|
# the OS-marked tests it collects are skipped there by design (see the
|
|
# `_OS_MARKS` comment in tests/conftest.py) — this is where they run.
|
|
# Gated on `python` and on each lane it consumes: desktop_updater is set by
|
|
# TypeScript-only changes too (the Electron marker / result readers), and with
|
|
# `python` alone those never reached the Windows hand-off tests below.
|
|
tests-os:
|
|
name: OS-specific tests
|
|
needs: detect
|
|
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.desktop_updater == 'true' || needs.detect.outputs.e2e == 'true' || needs.detect.outputs.e2e_upgrade == 'true'
|
|
uses: ./.github/workflows/tests-os.yml
|
|
with:
|
|
# The Windows lane spawns the real desktop-update hand-off script
|
|
# (tests/test_desktop_update_windows_*.py) only when that surface
|
|
# changed; unit-level platforms("windows") tests always run.
|
|
desktop_updater: ${{ needs.detect.outputs.desktop_updater == 'true' }}
|
|
e2e: ${{ needs.detect.outputs.e2e == 'true' }}
|
|
e2e_upgrade: ${{ needs.detect.outputs.e2e_upgrade == 'true' }}
|
|
strict_acceptance: ${{ inputs.strict_acceptance || '' }}
|
|
|
|
lint:
|
|
name: Python lints
|
|
needs: detect
|
|
# frontend too: the code-health ratchet in lint.yml also covers TypeScript.
|
|
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
|
|
uses: ./.github/workflows/lint.yml
|
|
with:
|
|
event_name: ${{ needs.detect.outputs.event_name }}
|
|
python: ${{ needs.detect.outputs.python == 'true' }}
|
|
|
|
js-tests:
|
|
name: JS & TS checks
|
|
needs: detect
|
|
if: needs.detect.outputs.frontend == 'true'
|
|
uses: ./.github/workflows/js-tests.yml
|
|
|
|
rust-tests:
|
|
name: Rust tests
|
|
needs: detect
|
|
# Only for PRs that touch a Rust crate. `.rs` is under apps/, so these
|
|
# changes used to run the TypeScript matrix and nothing that compiles them.
|
|
if: needs.detect.outputs.rust == 'true'
|
|
uses: ./.github/workflows/rust-tests.yml
|
|
|
|
bootstrap-installer:
|
|
name: Bootstrap installer
|
|
needs: detect
|
|
# The bootstrap-installer path: install.sh, the pin fragments it embeds,
|
|
# and the version stamp it ships. The PowerShell installer's native tests
|
|
# are platforms("windows") pytest files and run in tests-os.
|
|
if: needs.detect.outputs.bootstrap == 'true'
|
|
uses: ./.github/workflows/bootstrap-installer.yml
|
|
|
|
e2e-desktop:
|
|
name: Desktop E2E
|
|
needs: detect
|
|
# python_prod (not python): the Playwright suite exercises the built app
|
|
# + `hermes serve` backend, which never import anything under tests/.
|
|
# Tests-only PRs (~17% of commits) skip this 5-minute job — the longest
|
|
# single job in the workflow — while still running the full pytest lanes.
|
|
#
|
|
# Re-disabled (Sep 2026): the Sep 1 re-enable is still incredibly flaky.
|
|
# Keep this a bare `if: false`. The earlier
|
|
# `${{ false && (... || ...) }}` form on this reusable-workflow job made
|
|
# GitHub's workflow parser fail at startup ("An unexpected error has
|
|
# occurred") — every ci.yaml run repo-wide dispatched 0 jobs from
|
|
# 24f5a60ed1 until this line changed. To re-enable, restore:
|
|
# if: ${{ needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' }}
|
|
if: false
|
|
uses: ./.github/workflows/e2e-desktop.yml
|
|
|
|
e2e-desktop-core:
|
|
name: Desktop core E2E
|
|
needs: detect
|
|
# The deterministic core suite (apps/desktop/e2e/core): transcript
|
|
# integrity, boot/respawn/orphans, clarify+approval. Required; retries 0.
|
|
if: needs.detect.outputs.e2e_desktop_core == 'true'
|
|
uses: ./.github/workflows/e2e-desktop-core.yml
|
|
|
|
e2e-desktop-update:
|
|
name: Desktop update E2E
|
|
needs: detect
|
|
# apps/desktop/e2e/update: a real install.sh install + packaged Desktop,
|
|
# first run, "Update now" hand-off/relaunch, concurrent-update refusal.
|
|
# Required when it runs: a PR that touches the Desktop update path (the
|
|
# classifier's e2e_desktop_update lane) cannot merge on a red shard. Like
|
|
# the Linux e2e-upgrade shards and the Windows install + update journey
|
|
# (tests / tests-os, gated on e2e_upgrade), it is skipped — and the skip
|
|
# counts as success — when the PR does not touch that path.
|
|
if: needs.detect.outputs.e2e_desktop_update == 'true'
|
|
uses: ./.github/workflows/e2e-desktop-update.yml
|
|
|
|
docs-site:
|
|
name: Docs Site
|
|
needs: detect
|
|
if: needs.detect.outputs.site == 'true'
|
|
uses: ./.github/workflows/docs-site-checks.yml
|
|
|
|
history-check:
|
|
name: Deny unrelated histories
|
|
needs: detect
|
|
if: needs.detect.outputs.event_name == 'pull_request'
|
|
uses: ./.github/workflows/history-check.yml
|
|
|
|
contributor-check:
|
|
name: Check contributors
|
|
needs: detect
|
|
if: needs.detect.outputs.python == 'true'
|
|
uses: ./.github/workflows/contributor-check.yml
|
|
|
|
uv-lockfile:
|
|
name: Check uv.lock
|
|
needs: detect
|
|
# Gated: `uv lock --check` re-resolves the whole dependency graph against
|
|
# PyPI, so on every PR it spent a network round-trip — and, on a registry
|
|
# blip, a blocking red X — for diffs that cannot desync the lockfile
|
|
# (docs, frontend, prose). Only pyproject.toml / uv.lock can. A
|
|
# `.github/` change still forces it on via the classifier's fail-open.
|
|
if: needs.detect.outputs.uv_lock == 'true'
|
|
uses: ./.github/workflows/uv-lockfile-check.yml
|
|
|
|
infographic-check:
|
|
name: Check no committed infographics
|
|
needs: detect
|
|
uses: ./.github/workflows/infographic-check.yml
|
|
|
|
profile-artifact-check:
|
|
name: Profile artifact check
|
|
needs: detect
|
|
uses: ./.github/workflows/profile-artifact-check.yml
|
|
|
|
icons-freshness-check:
|
|
name: Icon assets freshness
|
|
needs: detect
|
|
uses: ./.github/workflows/icons-freshness-check.yml
|
|
|
|
case-collision-check:
|
|
name: Check no case-colliding filenames
|
|
needs: detect
|
|
uses: ./.github/workflows/case-collision-check.yml
|
|
|
|
lazy-deps-guard:
|
|
name: No production imports of the tools.lazy_deps stub
|
|
needs: detect
|
|
uses: ./.github/workflows/lazy-deps-guard.yml
|
|
|
|
lockfile-diff:
|
|
name: package-lock.json diff
|
|
needs: detect
|
|
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
|
|
uses: ./.github/workflows/lockfile-diff.yml
|
|
|
|
docker-lint:
|
|
name: Lint Docker scripts
|
|
needs: detect
|
|
if: needs.detect.outputs.docker_meta == 'true'
|
|
uses: ./.github/workflows/docker-lint.yml
|
|
|
|
supply-chain:
|
|
name: Supply-chain scan
|
|
needs: detect
|
|
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
|
|
uses: ./.github/workflows/supply-chain-audit.yml
|
|
with:
|
|
event_name: ${{ needs.detect.outputs.event_name }}
|
|
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
|
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
|
# status even when some deps were skipped.
|
|
#
|
|
# Non-release (PR/push): failure fails. Update/E2E consumers selected
|
|
# by detect.outputs must succeed; only unselected consumers may skip.
|
|
# Release (inputs.release): strict — every required job must be
|
|
# `success`. A skipped required lane fails the gate; only the PR-only
|
|
# jobs (history-check, lockfile-diff, supply-chain) and
|
|
# the deferred Desktop E2E (e2e-desktop) may skip. The OSV scan's
|
|
# findings are advisory, but its execution is required.
|
|
#
|
|
# Branch protection should require ONLY this check.
|
|
#
|
|
# The check name carries a version. The ruleset requires a check NAME, and a
|
|
# green check run recorded on a PR head stays valid forever, so a PR whose
|
|
# last CI predates a new required job still merges on that old green (32
|
|
# PRs merged without the code-health ratchet that way). When the required
|
|
# set gains a job every open PR must pass, bump the version here and in the
|
|
# protect-main ruleset in the same rollout: older greens then no longer
|
|
# count, and each PR needs one fresh CI run before it can merge.
|
|
#
|
|
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
|
|
# the live comment poller can list failed jobs in the PR comment.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
all-checks-pass:
|
|
name: All required checks pass (v2)
|
|
needs:
|
|
- detect
|
|
- tests
|
|
- tests-os
|
|
- lint
|
|
- js-tests
|
|
- rust-tests
|
|
- bootstrap-installer
|
|
- e2e-desktop
|
|
- e2e-desktop-core
|
|
- e2e-desktop-update
|
|
- docs-site
|
|
- history-check
|
|
- contributor-check
|
|
- uv-lockfile
|
|
- infographic-check
|
|
- case-collision-check
|
|
- lazy-deps-guard
|
|
- lockfile-diff
|
|
- docker-lint
|
|
- profile-artifact-check
|
|
- icons-freshness-check
|
|
- supply-chain
|
|
# OSV runs weekly against main (osv-scanner.yml schedule), not per PR:
|
|
# every PR was reporting the same repo-wide baseline of pinned-dep CVEs
|
|
# in its review comment, and the SARIF upload tripped GitHub's
|
|
# per-installation API rate limit during merge trains.
|
|
# The image build runs in its own workflow (docker.yml) and reports
|
|
# its own check. It was never required here, because it is too slow
|
|
# to block a merge. A separate run also stops it from holding this
|
|
# run open. That is what blocked ``gh run rerun``.
|
|
if: always()
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
outputs:
|
|
needs-json: ${{ steps.evaluate.outputs.needs-json }}
|
|
steps:
|
|
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
- name: Evaluate job results
|
|
id: evaluate
|
|
# Shared with the stable orchestrator (scripts/ci/required_results.py
|
|
# is imported there). NEEDS is the toJSON(needs) context; RELEASE
|
|
# switches the gate into strict mode for release runs.
|
|
env:
|
|
NEEDS: ${{ toJSON(needs) }}
|
|
RELEASE: ${{ inputs.release }}
|
|
run: |
|
|
args=()
|
|
if [ "$RELEASE" = true ]; then args+=(--release); fi
|
|
printf '%s' "$NEEDS" | python3 scripts/ci/required_results.py "${args[@]}"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# CI timing report: collect per-job/step durations from the GitHub API,
|
|
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
|
# report with a gantt chart + per-step breakdown. The report is uploaded
|
|
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
|
#
|
|
# The live comment poller dynamically fetches all review-status-* artifacts
|
|
# across the orchestrator and sub-workflow runs every cycle, so its link
|
|
# points straight at that report.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
ci-timings:
|
|
name: CI timing report
|
|
needs: [all-checks-pass]
|
|
if: always()
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
|
|
|
|
- name: Restore baseline cache (PR only)
|
|
if: github.event_name == 'pull_request'
|
|
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: ci-timings-baseline.json
|
|
# Prefix-match: exact key will never hit (run_id differs), so
|
|
# restore-keys finds the most recent baseline from main.
|
|
key: ci-timings-baseline-never-exact
|
|
restore-keys: |
|
|
ci-timings-baseline-
|
|
|
|
- name: Collect timings and generate report
|
|
env:
|
|
GITHUB_TOKEN: ${{ github.token }}
|
|
run: |
|
|
python3 scripts/ci/timings_report.py \
|
|
--baseline ci-timings-baseline.json \
|
|
--output ci-timings-report.html \
|
|
--json-out ci-timings.json \
|
|
--summary-out ci-timings-summary.md
|
|
|
|
- name: Upload HTML report
|
|
# Advisory report — artifact-service blips must not fail the job.
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
id: ci-timings-html
|
|
with:
|
|
name: ci-timings-report
|
|
path: ci-timings-report.html
|
|
retention-days: 14
|
|
|
|
- name: Build linked review status
|
|
if: hashFiles('ci-timings.json') != ''
|
|
env:
|
|
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
|
|
run: |
|
|
python3 scripts/ci/timings_report.py \
|
|
--from-json ci-timings.json \
|
|
--baseline ci-timings-baseline.json \
|
|
--review-status-out review-status.json \
|
|
--review-status-only
|
|
|
|
- name: Upload review status
|
|
if: hashFiles('review-status.json') != ''
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: review-status-ci-timings
|
|
path: review-status.json
|
|
retention-days: 14
|
|
|
|
- name: Output summary
|
|
env:
|
|
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
|
|
run: |
|
|
{
|
|
echo "# CI Timing report"
|
|
echo "[View the full interactive report]($REPORT_URL)"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Save baseline cache (main only)
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
run: |
|
|
# Degraded runs (API rate-limited) produce no ci-timings.json —
|
|
# skip rather than fail, and never cache an empty baseline.
|
|
if [ -f ci-timings.json ]; then
|
|
cp ci-timings.json ci-timings-baseline.json
|
|
else
|
|
echo "No timings JSON this run — skipping baseline update"
|
|
fi
|
|
|
|
- name: Upload baseline to cache (main only)
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
|
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
with:
|
|
path: ci-timings-baseline.json
|
|
key: ci-timings-baseline-${{ github.run_id }}
|