From 5f27f088cfec115ec2576d17fb3310602472a573 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:35:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(ci):=20seed=20v0.3-dev=20release-gate?= =?UTF-8?q?=20branch=20=F0=9F=8C=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c955cf2b15112a5f7bd3298dc36c8abe643a3886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darien=20Hern=C3=A1ndez=20Gonz=C3=A1lez?= Date: Fri, 19 Jun 2026 12:22:54 +0200 Subject: [PATCH 2/6] fix(build): repoint frozen gen-00 zlib fetch to genvm-artifacts mirror (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit all.nix builds gen-00 (frozen v0.2.x runners). Their runners/cpython/deps/ zlib.nix hardcodes www.zlib.net/zlib-1.3.1.tar.gz (now 404 — zlib.net moved 1.3.1 to /fossils/) with no mirror. The head recipe produces the SAME genvm-zlib-src output (same name + sha256, via dependency-urls.json) but with a mirror, so nix realizes one of two same-output .drvs non-deterministically: pick head and the dead URL falls back to the GCS mirror; pick the frozen one and it 404s with no fallback -> ~50% of from-source runners-all builds fail. Rewrite the frozen URL to the genvm-artifacts GCS mirror (head's own fallback, which we control) before import so BOTH .drvs are resilient. fetchzip is sha256-addressed, so the output path and all downstream runner hashes are preserved. Preserve file modes and rewrite only zlib.nix's contents: the frozen trees ship executable build scripts (numpy's deps/stub-clang.py, 0755) and a blanket cp --no-preserve=mode strips the exec bit, making the numpy configure phase die with exit 126. Co-authored-by: Claude Opus 4.8 (1M context) --- runners/support/versions/all.nix | 2 +- runners/support/versions/generation-00.nix | 46 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/runners/support/versions/all.nix b/runners/support/versions/all.nix index 5d65d9fdc..208cea455 100644 --- a/runners/support/versions/all.nix +++ b/runners/support/versions/all.nix @@ -2,6 +2,6 @@ , ... }@args: let - gen-00 = import ./generation-00.nix { repo = build-config.repo-url; }; + gen-00 = import ./generation-00.nix { repo = build-config.repo-url; inherit (args) pkgs; }; head = import ./head.nix args; in gen-00 ++ head diff --git a/runners/support/versions/generation-00.nix b/runners/support/versions/generation-00.nix index 3a9da6e3d..c16579660 100644 --- a/runners/support/versions/generation-00.nix +++ b/runners/support/versions/generation-00.nix @@ -1,4 +1,5 @@ { repo ? "https://github.com/genlayerlabs/genvm.git" +, pkgs , ... }: let @@ -8,6 +9,48 @@ let "fed444c0d9537f41a6ccafeac7c7507a2cd8f69e" # v0.2.4 ]; + # These frozen v0.2.x runner trees hardcode the upstream zlib tarball URL in + # runners/cpython/deps/zlib.nix with NO mirror. The current ("head") recipe + # fetches the SAME genvm-zlib-src output (identical name + sha256, via + # dependency-urls.json) but with a mirror list — so both produce the same + # /nix/store output path through two different .drvs. nix realizes only one of + # them, non-deterministically: pick the head .drv and the dead zlib.net URL + # falls back to the GCS mirror and builds; pick this frozen .drv and there is + # no mirror, so it 404s and the whole runners-all build fails (~50% of + # from-source CI builds). zlib.net moved 1.3.1 under /fossils/, hence the 404. + # We rewrite the dead URL to the genvm-artifacts GCS mirror — the same artifact + # the head recipe already falls back to, which we control — in a copy of the + # fetched tree before importing it, so BOTH .drvs are resilient regardless of + # which one nix schedules. fetchzip is content-addressed by its sha256, so the + # genvm-zlib-src output path (and every downstream runner output hash) is + # unchanged; only the source URL changes. The grep guard fails loudly if a rev + # no longer carries the expected dead URL, so this never silently no-ops. + # + # IMPORTANT: --preserve=mode + a content-only rewrite of zlib.nix. The frozen + # trees ship executable build scripts (e.g. numpy's deps/stub-clang.py, 0755); + # a blanket `cp --no-preserve=mode` strips those bits and the numpy configure + # phase dies with exit 126 (Permission denied). So we keep every file's mode + # and touch ONLY zlib.nix's contents — the rest of the tree stays byte- and + # mode-identical, so downstream runner FOD outputs match their pinned hashes. + patchOldSrc = rev: src: + pkgs.runCommandLocal + "genvm-runners-src-${builtins.substring 0 12 rev}-zlib-mirror" + { } + '' + cp -r --preserve=mode ${src} "$out" + zlibNix="$out/runners/cpython/deps/zlib.nix" + if ! grep -q 'https://www.zlib.net/zlib-1.3.1.tar.gz' "$zlibNix"; then + echo "generation-00 zlib patch: expected dead URL not found in $zlibNix (rev ${rev}); patch is stale" >&2 + exit 1 + fi + chmod u+w "$zlibNix" + tmp="$(mktemp)" + sed 's|https://www.zlib.net/zlib-1.3.1.tar.gz|https://storage.googleapis.com/genvm-artifacts/zlib-1.3.1.tar.gz|g' \ + "$zlibNix" > "$tmp" + cat "$tmp" > "$zlibNix" + rm -f "$tmp" + ''; + mapRev = rev: let src = builtins.fetchGit { @@ -17,8 +60,9 @@ let shallow = true; submodules = true; }; + patchedSrc = patchOldSrc rev src; in - builtins.map (x: x // { inherit rev; }) (import "${src}/runners") + builtins.map (x: x // { inherit rev; }) (import "${patchedSrc}/runners") ; in # list[{id, hash, rev, derivation}] From 09744264f2a8196f12b89291a0f9da19e846df6f Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 25 Jun 2026 21:27:23 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(ci):=20forward=20main=20from=20v-dev?= =?UTF-8?q?,=20drop=20outdated=20branch-model=20doc=20=F0=9F=8F=97?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/branch_forward.yaml | 16 +-- support/ci/branch-model.md | 140 -------------------------- 2 files changed, 8 insertions(+), 148 deletions(-) delete mode 100644 support/ci/branch-model.md diff --git a/.github/workflows/branch_forward.yaml b/.github/workflows/branch_forward.yaml index d715dbafe..b26892615 100644 --- a/.github/workflows/branch_forward.yaml +++ b/.github/workflows/branch_forward.yaml @@ -1,10 +1,10 @@ name: branch / forward version to main # main is the default branch but is only an ALIAS of the latest active -# version branch (v.x): it always points at that branch's tip and -# is never committed to directly. On every push to a version branch we +# dev branch (v-dev): it always points at that branch's tip and +# is never committed to directly. On every push to a dev branch we # fast-forward main to it — but only for the LATEST active version, so -# parallel trains (e.g. v0.3.x and v0.6.x) don't fight over main. +# parallel trains (e.g. v0.3-dev and v0.6-dev) don't fight over main. # # main is protected, so the default GITHUB_TOKEN cannot push to it. We # push over SSH with a write deploy key (GENVM_CI_PRIVATE_KEY), which must @@ -14,7 +14,7 @@ name: branch / forward version to main on: push: - branches: ['v*.x'] + branches: ['v*-dev'] permissions: contents: read @@ -35,17 +35,17 @@ jobs: # fast-forward and the server rejects it ("fetch first"). fetch-depth: 0 - - name: Fast-forward main to latest version branch + - name: Fast-forward main to latest dev branch run: | BRANCH="${GITHUB_REF_NAME}" # Decide "latest" from main's active-versions (the authoritative - # alias), NOT the pushed branch's own copy — an older version + # alias), NOT the pushed branch's own copy — an older dev # branch carries a stale active-versions and would otherwise try # to drag main backwards once a newer train exists. git show origin/main:.genvm-monorepo-root > /tmp/main-monorepo-root.json LATEST="$(MONOREPO_ROOT=/tmp/main-monorepo-root.json python3 support/ci/branch-versions.py latest)" - if [ "$BRANCH" != "v${LATEST}.x" ]; then - echo "Pushed branch ${BRANCH} is not the latest version branch (v${LATEST}.x); nothing to do" + if [ "$BRANCH" != "v${LATEST}-dev" ]; then + echo "Pushed branch ${BRANCH} is not the latest dev branch (v${LATEST}-dev); nothing to do" exit 0 fi echo "Fast-forwarding main to ${GITHUB_SHA} (from ${BRANCH})" diff --git a/support/ci/branch-model.md b/support/ci/branch-model.md deleted file mode 100644 index f65af5786..000000000 --- a/support/ci/branch-model.md +++ /dev/null @@ -1,140 +0,0 @@ -# GenVM branch model - -GenVM uses an integration/release split per version train, mirroring the -rest of the v0.6 fee train. Active trains are declared in -[`.genvm-monorepo-root`](../../.genvm-monorepo-root) under -`active-versions` (e.g. `["0.3"]`). - -For each active version `X`: - -| Branch | Role | Protected | -|------------|--------------------------------------------------------------|-----------| -| `v-dev` | Integration. All work lands here; may be red during a train. | yes | -| `v.x` | Release-ready. Only updated via the standing release-gate PR.| yes | -| `main` | Default branch; an **alias** of the latest `v.x`. | yes | - -## Flow - -``` -feature branch ──PR──▶ v-dev ──standing PR (E2E gate)──▶ v.x ──fast-forward──▶ main (latest train only) -``` - -- **Contributions** target `v-dev`. A PR opened against `main` is - retargeted to the latest dev branch automatically - (`branch_retarget.yaml`). -- **Merge queue** may only merge into a `v-dev` branch - (`branch_queue_guard.yaml` fails otherwise). Normal GenVM CI - (`queue.yaml`) runs in the queue as before. -- **Release gate**: a standing PR `v-dev → v.x` stays open. It is - gated by the cross-repo E2E pipeline owned by - [`genlayerlabs/genlayer-e2e`](https://github.com/genlayerlabs/genlayer-e2e) - (synced into this repo as `.github/workflows/e2e.yml`; **not** a - `branch_*` workflow — see below). A maintainer comments - `/run-e2e ` on the standing PR to fire it; the pipeline posts a - check-run that branch protection on `v.x` requires, so the PR merges - only once E2E is green. -- **main** is never committed to directly. On every push to the latest - version branch, `branch_forward.yaml` fast-forwards `main` to it, so - `main` always equals `v.x`. -- **Provisioning**: `branch_provision.yaml` creates any missing version - branch, dev branch, and standing PR for every entry in - `active-versions` (idempotent). - -## Workflows (all `branch_*`) - -| File | Trigger | Does | -|----------------------------|--------------------------------------|-----------------------------------------------| -| `branch_forward.yaml` | push to `v*.x` | fast-forward `main` to the latest `v.x` | -| `branch_retarget.yaml` | PR opened/reopened against `main` | retarget to `v-dev` + comment | -| `branch_provision.yaml` | dispatch / `active-versions` change | create dev/version branches + standing PRs | -| `branch_queue_guard.yaml` | `merge_group` | fail unless the queue target is `v-dev` | -| `branch_new_version.yaml` | dispatch | cut the next train; push `main` + `v.x` + `v-dev` | - -The E2E release gate is **not** a `branch_*` workflow. The pipeline -(`.github/workflows/e2e.yml`) and its cache/artifact housekeeper -(`.github/workflows/e2e-housekeeper.yml`) are source-of-truth templates -owned by `genlayerlabs/genlayer-e2e` and synced into this repo -byte-identically via its sync PRs — GenVM is registered in that repo's -`repos.yaml` (`component: genvm`, `gate_policy: release-branches`). Don't -hand-edit them here; changes ship as a sync PR from genlayer-e2e. They run -on a `/run-e2e` PR comment, not on the branch model's events. - -`support/ci/branch-versions.py` reads `active-versions` (`list`/`latest`; -honors a `MONOREPO_ROOT` env override). `support/ci/provision-branches.sh` -is the provisioning logic. - -### Cutting a new version train - -`branch_new_version.yaml` (Actions → run, pick `minor`/`major` or set an -explicit version) does it end to end: `check-versions.py bump` raises -`major-minor`, appends to `active-versions`, and sets the crate -[package] versions to `.0`; the commit is pushed to `main`, -`v.x` and `v-dev`. The `v-dev` push then trips -`branch_provision`, which opens the standing release-gate PR. - -> `.genvm-monorepo-root`'s `major-minor` is unrelated to -> `active-versions`: it pins the single major.minor that *this branch's* -> crates must match. The `check-versions.py` pre-commit hook is a -> **fixer** — it rewrites the crate `[package]` versions to that -> major.minor (patch kept) and fails if it had to, so you re-stage. On -> `v0.3-dev` and `v0.3.x` it is `0.3`. - -## One-time setup runbook (repo admin) - -These are live, irreversible operations — run them yourself with an admin -token. `` = latest active version (`0.3` today). - -```sh -# 0. Deploy key — branch_forward / branch_provision push over SSH. -# Add the GENVM_CI_PRIVATE_KEY public key as a repo deploy key with -# write access, and put it on each protected branch ruleset's bypass -# list (steps 3-5). (Already configured for the old fast-forward flow.) - -# 1. Create the version + dev branches and the standing PR. -# Either run the branch_provision workflow from the Actions tab, or: -git fetch origin -git push origin "origin/main:refs/heads/v${X}.x" # if absent -git push origin "origin/v${X}.x:refs/heads/v${X}-dev" # if absent -gh pr create --base "v${X}.x" --head "v${X}-dev" \ - --title "Release gate: v${X}-dev → v${X}.x" \ - --body "Standing release-gate PR." - -# 2. Make v-dev the default branch (main stays, as an alias). -gh repo edit --default-branch "v${X}-dev" -``` - -### Branch protection (all three protected) - -For `v-dev`, `v.x`, and `main` create a ruleset (or classic -protection) that: - -- requires PRs (no direct pushes), -- bars force-push and deletion, -- bypass: the `GENVM_CI_PRIVATE_KEY` deploy key (so `branch_forward` / - `branch_provision` can push). - -Required status checks: - -- `v-dev`: normal GenVM CI via the **merge queue** (`queue.yaml`) plus - `branch / merge-queue target guard`. Enable the merge queue for this - branch. -- `v.x`: the genlayer-e2e E2E check-run, posted when a maintainer runs - `/run-e2e ` on the standing PR (select it as a required check - once the first run surfaces it in the checks list). This PR may stay red - while the train is in progress; it merges only when E2E is green. -- `main`: protected, fast-forward-only by the deploy key; no required - checks needed (content already validated upstream). - -### Retiring `main` as a development target - -`main` is **not deleted** — it remains the default-visible alias of the -latest release branch. Just ensure nothing pushes to it directly; the -`branch_retarget` workflow moves stray PRs to the dev branch. - -## Adding a new version train (e.g. 0.6) - -1. Add `"0.6"` to `active-versions` in `.genvm-monorepo-root` (on - `main` / the dev branches; keep it consistent across branches). -2. `branch_provision` creates `v0.6.x`, `v0.6-dev`, and the standing PR. -3. Once `0.6` is the highest active version, `branch_forward` starts - aliasing `main` to `v0.6.x`; flip the default branch to `v0.6-dev`. From 5b6fb7b3f6bf8908051e7aaa34017dba30fd25c0 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 25 Jun 2026 21:39:47 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(ci):=20replace=20merge=20queue=20with?= =?UTF-8?q?=20/merge=20comment=20gate=20into=20dev=20=F0=9F=8F=97=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/get-src/action.yaml | 2 +- .github/workflows/branch_merge_into_dev.yaml | 72 +++++++ .github/workflows/branch_queue_guard.yaml | 35 --- .github/workflows/incl_initial.yaml | 28 +++ .github/workflows/pr.yaml | 53 ----- .github/workflows/queue.yaml | 9 +- support/ci/merge-into-dev.py | 212 +++++++++++++++++++ 7 files changed, 321 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/branch_merge_into_dev.yaml delete mode 100644 .github/workflows/branch_queue_guard.yaml delete mode 100644 .github/workflows/pr.yaml create mode 100755 support/ci/merge-into-dev.py diff --git a/.github/actions/get-src/action.yaml b/.github/actions/get-src/action.yaml index 988ebcb3b..311ada577 100644 --- a/.github/actions/get-src/action.yaml +++ b/.github/actions/get-src/action.yaml @@ -22,7 +22,7 @@ runs: - name: checkout submodules run: | cd "$GITHUB_WORKSPACE" - git config --global user.email "worker@ci.ci" + git config --global user.email "ci@genlayerlabs.com" git config --global user.name "CI worker" if [ "${{ inputs.load_submodules }}" == "true" ] then diff --git a/.github/workflows/branch_merge_into_dev.yaml b/.github/workflows/branch_merge_into_dev.yaml new file mode 100644 index 000000000..b265e1ae0 --- /dev/null +++ b/.github/workflows/branch_merge_into_dev.yaml @@ -0,0 +1,72 @@ +name: branch / merge PR into dev + +# The repo has NO GitHub merge queue. A maintainer merges a PR into a dev +# branch (v-dev) by commenting `/merge`. This workflow re-checks every +# gate against the EXACT head commit and then advances the dev branch by a +# plain (fast-forward-only) push, so what lands is byte-identical to what +# CI and E2E validated. +# +# Gates (all required): +# 1. base branch is a v-dev branch +# 2. PR carries the `rtm` (ready-to-merge) label +# 3. full GenVM CI (queue.yaml) concluded success on the head commit +# 4. the cross-repo E2E check concluded success on the head commit +# 5. the PR is 0 commits behind base (head already contains base tip) +# +# Merge strategy: +# - 1 commit -> fast-forward the original commit (SHA preserved) +# - more than 1 commit -> squash into a single commit on top of base, +# then fast-forward +# Either way the PR is closed afterwards. +# +# The dev branches are protected; the default GITHUB_TOKEN cannot push to +# them. We push over SSH with the GENVM_CI_PRIVATE_KEY deploy key (on the +# dev-branch ruleset bypass list). Pushes are non-force, so a base that +# advanced between the checks and the push is safely rejected. This job +# never runs PR code — only git plumbing and API reads — so the SSH key is +# not exposed to untrusted code, and the author_association gate further +# limits who can fire it. + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + checks: read + actions: read + +defaults: + run: + shell: bash -x {0} + +env: + # check-run name (case-insensitive substring) the genlayer-e2e pipeline + # posts on the head commit. Adjust if that pipeline renames its check. + E2E_CHECK_PATTERN: "e2e" + +jobs: + merge: + # Only on PR comments that start with /merge, from a maintainer + # (write access ~ OWNER/MEMBER/COLLABORATOR). + if: > + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/merge') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + steps: + # Checkout with the deploy key so the (protected) dev branch push in + # the merge script succeeds. The script never runs PR code. + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.GENVM_CI_PRIVATE_KEY }} + fetch-depth: 0 + + - name: Validate gates, fast-forward / squash, close + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + run: python3 support/ci/merge-into-dev.py diff --git a/.github/workflows/branch_queue_guard.yaml b/.github/workflows/branch_queue_guard.yaml deleted file mode 100644 index b9f7b955f..000000000 --- a/.github/workflows/branch_queue_guard.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: branch / merge-queue target guard - -# The merge queue may only merge into a dev branch (v-dev). Any PR -# whose target resolves to a non-dev branch (main, a version branch, a -# feature branch) fails here, so the queue stays the single funnel into -# the integration branches. -# -# Runs alongside the full GenVM merge_group checks (queue.yaml); a -# failure here fails the merge_group regardless of those results. - -on: - merge_group: - -permissions: - contents: read - -defaults: - run: - shell: bash -x {0} - -jobs: - guard: - runs-on: ubuntu-latest - steps: - - name: Require merge-queue target to be a dev branch - run: | - BASE="${{ github.event.merge_group.base_ref }}" - NAME="${BASE#refs/heads/}" - case "$NAME" in - v*-dev) echo "ok: merging into dev branch ${NAME}" ;; - *) - echo "::error::Merge queue may only target v-dev branches; got '${NAME}'" - exit 1 - ;; - esac diff --git a/.github/workflows/incl_initial.yaml b/.github/workflows/incl_initial.yaml index adceb5682..99f64cec8 100644 --- a/.github/workflows/incl_initial.yaml +++ b/.github/workflows/incl_initial.yaml @@ -20,3 +20,31 @@ jobs: - uses: pre-commit/action@v3.0.1 with: extra_args: --all-files --show-diff-on-failure + + # The /merge comment only fast-forwards, so the PR head must already + # contain the base tip (0 commits behind). We assert it here too so the + # required "genvm CI" gate goes red the moment the branch falls behind, + # instead of surfacing only at merge time. + behind-check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Ensure 0 commits behind base + env: + BASE: ${{ github.event.pull_request.base.ref }} + PR: ${{ github.event.pull_request.number }} + run: | + # Resolve base tip and PR head via explicit refs so this also + # works for fork PRs (head.sha is not fetchable by sha there). + git fetch --no-tags origin \ + "+refs/heads/${BASE}:refs/base" \ + "refs/pull/${PR}/head:refs/prhead" + if ! git merge-base --is-ancestor refs/base refs/prhead; then + BEHIND="$(git rev-list --count refs/prhead..refs/base)" + echo "::error::PR is ${BEHIND} commit(s) behind ${BASE}; update/rebase the branch so it is 0 behind before merging" + exit 1 + fi + echo "0 commits behind ${BASE}" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml deleted file mode 100644 index e7c863aa8..000000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: PR fast check -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] -permissions: - contents: read - -defaults: - run: - shell: bash -x {0} - -env: - GCS_BUCKET: "gh-af" - -jobs: - initial: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'test:skip:pr') }} - uses: ./.github/workflows/incl_initial.yaml - secrets: inherit - - module-test-python: - if: ${{ !contains(github.event.pull_request.labels.*.name, 'test:skip:pr') }} - needs: [initial] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - lfs: true - - name: Get source - uses: ./.github/actions/get-src - with: - load_submodules: "false" - with_nix: "true" - github_token: ${{ secrets.GITHUB_TOKEN }} - - run: ./support/ci/pipelines/test-python.sh - - validate-end: - runs-on: ubuntu-latest - if: ${{ always() }} - needs: - - initial - - module-test-python - steps: - - name: check - run: | - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'test:skip:pr') }}" == "true" ]]; then - echo "PR tests skipped due to test:skip:pr label" - else - if echo "${{ join(needs.*.result, ' ') }}" | grep -Eq 'failure|cancelled'; then - echo "Detected failed or cancelled job(s): ${{ join(needs.*.result, ' ') }}" - exit 1 - fi - fi diff --git a/.github/workflows/queue.yaml b/.github/workflows/queue.yaml index 92fb34d7e..33c608c84 100644 --- a/.github/workflows/queue.yaml +++ b/.github/workflows/queue.yaml @@ -1,6 +1,13 @@ name: GenVM full + +# Full GenVM CI. The repo has NO GitHub merge queue: this runs directly on +# every PR targeting a dev branch (v-dev) and is the authoritative +# "genvm CI" gate the /merge comment (branch_merge_into_dev.yaml) requires to be green on +# the exact head commit it fast-forwards. on: - merge_group: + pull_request: + branches: ['v*-dev'] + types: [opened, synchronize, reopened, ready_for_review] defaults: run: shell: bash -x {0} diff --git a/support/ci/merge-into-dev.py b/support/ci/merge-into-dev.py new file mode 100755 index 000000000..41884aaa9 --- /dev/null +++ b/support/ci/merge-into-dev.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Gate and perform a `/merge` of a PR into a v-dev branch. + +Invoked by .github/workflows/branch_merge_into_dev.yaml on a maintainer +`/merge` comment. It re-checks every gate against the EXACT PR head commit +and then advances the dev branch by a plain (fast-forward-only) push, so +what lands is byte-identical to what CI and E2E validated. + +Gates (all required, all on the head commit): +1. base branch is a v-dev branch +2. PR carries the `rtm` (ready-to-merge) label +3. full GenVM CI (queue.yaml) concluded success +4. the cross-repo E2E check concluded success +5. the PR is 0 commits behind base + +Strategy: 1 commit -> fast-forward the original commit (SHA preserved); +more -> squash into one commit on top of base, then fast-forward. The PR +is closed afterwards. + +Talks to GitHub through the `gh` CLI and moves refs through `git`; it +never executes PR code. The dev branches are protected, so the workflow +checks out with the GENVM_CI_PRIVATE_KEY deploy key and pushes non-force +(a base that advanced is safely rejected). + +Env: GITHUB_REPOSITORY, PR_NUMBER, GH_TOKEN, E2E_CHECK_PATTERN, COMMENT_ID. +""" + +import json +import os +import re +import subprocess +import sys + +REPO = os.environ['GITHUB_REPOSITORY'] +PR = os.environ['PR_NUMBER'] +E2E_PATTERN = os.environ.get('E2E_CHECK_PATTERN', 'e2e') +COMMENT_ID = os.environ.get('COMMENT_ID') + + +def run(*args, check=True): + return subprocess.run(args, check=check, text=True, capture_output=True) + + +def gh(*args): + return run('gh', *args).stdout + + +def git(*args, check=True): + return run('git', *args, check=check) + + +def block(msg): + """Comment the reason on the PR and fail the job.""" + run( + 'gh', + 'pr', + 'comment', + PR, + '--repo', + REPO, + '--body', + f'❌ `/merge` blocked: {msg}', + check=False, + ) + sys.exit(1) + + +def pr_view(*fields): + out = gh('pr', 'view', PR, '--repo', REPO, '--json', ','.join(fields)) + return json.loads(out) + + +def check_gates(pr): + if pr['state'] != 'OPEN': + block(f"PR is not open (state: {pr['state']}).") + if pr['isDraft']: + block('PR is a draft.') + + base = pr['baseRefName'] + if not re.fullmatch(r'v.*-dev', base): + block(f'base branch `{base}` is not a `v-dev` branch.') + + # 2. rtm label + if not any(label['name'] == 'rtm' for label in pr['labels']): + block('missing the `rtm` (ready-to-merge) label.') + + head_sha = pr['headRefOid'] + + # 3. full GenVM CI (queue.yaml) green on the head commit + runs = json.loads( + gh( + 'api', + f'repos/{REPO}/actions/workflows/queue.yaml/runs' + f'?head_sha={head_sha}&event=pull_request', + ) + )['workflow_runs'] + latest = runs[0] if runs else None + if not latest or latest['status'] != 'completed' or latest['conclusion'] != 'success': + got = 'no run' if not latest else f"{latest['status']} {latest['conclusion']}" + block(f'GenVM CI (queue.yaml) is not green on `{head_sha}` (got: {got}).') + + # 4. cross-repo E2E check green on the head commit + checks = json.loads( + gh( + 'api', + f'repos/{REPO}/commits/{head_sha}/check-runs?per_page=100', + ) + )['check_runs'] + e2e = [c for c in checks if re.search(E2E_PATTERN, c['name'], re.I)] + if not e2e: + block(f'no E2E check found on `{head_sha}` (run it on this PR first).') + bad = [c for c in e2e if c['conclusion'] != 'success'] + if bad: + conclusions = ' '.join(c['conclusion'] or 'pending' for c in e2e) + block(f'E2E is not green on `{head_sha}` (conclusions: {conclusions}).') + + return base, head_sha + + +def merge(pr, base, head_sha): + # Fetch the exact head commit (works for fork PRs too) and live base tip. + git( + 'fetch', + '--no-tags', + 'origin', + f'refs/pull/{PR}/head:refs/prhead', + f'+refs/heads/{base}:refs/remotes/origin/{base}', + ) + + fetched = git('rev-parse', 'refs/prhead').stdout.strip() + if fetched != head_sha: + block( + f'head moved during merge (expected `{head_sha}`, got `{fetched}`); re-run /merge.' + ) + + # 5. authoritative 0-commits-behind check at merge time. + if ( + git( + 'merge-base', '--is-ancestor', f'origin/{base}', 'refs/prhead', check=False + ).returncode + != 0 + ): + behind = git('rev-list', '--count', f'refs/prhead..origin/{base}').stdout.strip() + block( + f'PR is {behind} commit(s) behind `{base}`; update the branch and re-run /merge.' + ) + + git('config', 'user.name', 'genvm-ci') + git('config', 'user.email', 'genvm-ci@genlayer.com') + + if len(pr['commits']) == 1: + print(f'Single commit: fast-forwarding {base} to {head_sha}') + push_sha = head_sha + else: + print(f"Squashing {len(pr['commits'])} commits onto {base}") + author = git('log', '-1', '--format=%an <%ae>', head_sha).stdout.strip() + git('checkout', '-B', '_merge', f'origin/{base}') + git('merge', '--squash', head_sha) + message = f"{pr['title']} (#{PR})\n\n{pr['body'] or ''}\n" + git('commit', '--author', author, '-m', message) + push_sha = git('rev-parse', 'HEAD').stdout.strip() + + # Non-force FF push; rejected if base advanced since the checks. + if ( + git('push', 'origin', f'{push_sha}:refs/heads/{base}', check=False).returncode != 0 + ): + block(f'fast-forward push to `{base}` was rejected (base advanced); re-run /merge.') + + run( + 'gh', + 'pr', + 'comment', + PR, + '--repo', + REPO, + '--body', + f'✅ Merged into `{base}` (`{push_sha}`) via fast-forward.', + check=False, + ) + run('gh', 'pr', 'close', PR, '--repo', REPO, check=False) + + +def main(): + if COMMENT_ID: + run( + 'gh', + 'api', + '--method', + 'POST', + f'repos/{REPO}/issues/comments/{COMMENT_ID}/reactions', + '-f', + 'content=eyes', + check=False, + ) + + pr = pr_view( + 'baseRefName', + 'headRefName', + 'headRefOid', + 'commits', + 'labels', + 'state', + 'isDraft', + 'title', + 'body', + ) + base, head_sha = check_gates(pr) + merge(pr, base, head_sha) + + +if __name__ == '__main__': + main() From 8e7ee935cf3ba46fe3326881a9d0be4aea4e24f8 Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 25 Jun 2026 22:23:56 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(ci):=20two-marker=20CI=20gate=20+=20ch?= =?UTF-8?q?eckbox=20PR=20action=20panel=20=F0=9F=8F=97=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework how PRs into v-dev are tested and merged. Markers (presence-checked labels): `rtm` (ready-to-merge) and `run-full-tests`; either one runs full tests. queue.yaml now runs the cheap `initial` checks (pre-commit + 0-behind) on every push, runs the heavy test jobs only when a marker is set, and fails validate-end when no marker is present so the gate stays red until full tests have run. Replace all /genvm-* slash commands with an auto-posted PR action panel (branch_pr_checklist.yaml) whose checkboxes — Force/Rerun full tests, Merge — are handled by branch_pr_actions.yaml + pr-action-panel.py. Any human may tick; the box unticks itself. "Force/Rerun full tests" re-applies the run-full-tests label; "Merge" calls the now-reusable branch_merge_into_dev.yaml (workflow_call). Authority is enforced by the merge gates, not by who ticks. Also: retarget comment aliases main from v-dev. --- .github/workflows/branch_merge_into_dev.yaml | 35 ++--- .github/workflows/branch_pr_actions.yaml | 59 ++++++++ .github/workflows/branch_pr_checklist.yaml | 67 +++++++++ .github/workflows/branch_retarget.yaml | 2 +- .github/workflows/incl_initial.yaml | 8 +- .github/workflows/queue.yaml | 40 ++++- ...ge-into-dev.py => genvm-merge-into-dev.py} | 46 +++--- support/ci/pr-action-panel.py | 139 ++++++++++++++++++ 8 files changed, 336 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/branch_pr_actions.yaml create mode 100644 .github/workflows/branch_pr_checklist.yaml rename support/ci/{merge-into-dev.py => genvm-merge-into-dev.py} (81%) create mode 100644 support/ci/pr-action-panel.py diff --git a/.github/workflows/branch_merge_into_dev.yaml b/.github/workflows/branch_merge_into_dev.yaml index b265e1ae0..7f6fd8a5e 100644 --- a/.github/workflows/branch_merge_into_dev.yaml +++ b/.github/workflows/branch_merge_into_dev.yaml @@ -1,10 +1,11 @@ name: branch / merge PR into dev -# The repo has NO GitHub merge queue. A maintainer merges a PR into a dev -# branch (v-dev) by commenting `/merge`. This workflow re-checks every -# gate against the EXACT head commit and then advances the dev branch by a -# plain (fast-forward-only) push, so what lands is byte-identical to what -# CI and E2E validated. +# The repo has NO GitHub merge queue. A PR lands on a dev branch (v-dev) +# when a maintainer ticks the "Merge" box on the PR action panel +# (branch_pr_actions.yaml), which calls this reusable workflow. It +# re-checks every gate against the EXACT head commit and then advances the +# dev branch by a plain (fast-forward-only) push, so what lands is +# byte-identical to what CI and E2E validated. # # Gates (all required): # 1. base branch is a v-dev branch @@ -23,13 +24,16 @@ name: branch / merge PR into dev # them. We push over SSH with the GENVM_CI_PRIVATE_KEY deploy key (on the # dev-branch ruleset bypass list). Pushes are non-force, so a base that # advanced between the checks and the push is safely rejected. This job -# never runs PR code — only git plumbing and API reads — so the SSH key is -# not exposed to untrusted code, and the author_association gate further -# limits who can fire it. +# never runs PR code — only git plumbing and API reads. The caller is +# responsible for restricting who can trigger a merge (maintainers only). on: - issue_comment: - types: [created] + workflow_call: + inputs: + pr_number: + description: number of the PR to merge + type: string + required: true permissions: contents: read @@ -49,12 +53,6 @@ env: jobs: merge: - # Only on PR comments that start with /merge, from a maintainer - # (write access ~ OWNER/MEMBER/COLLABORATOR). - if: > - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/merge') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest steps: # Checkout with the deploy key so the (protected) dev branch push in @@ -67,6 +65,5 @@ jobs: - name: Validate gates, fast-forward / squash, close env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.issue.number }} - COMMENT_ID: ${{ github.event.comment.id }} - run: python3 support/ci/merge-into-dev.py + PR_NUMBER: ${{ inputs.pr_number }} + run: python3 support/ci/genvm-merge-into-dev.py diff --git a/.github/workflows/branch_pr_actions.yaml b/.github/workflows/branch_pr_actions.yaml new file mode 100644 index 000000000..b6087e728 --- /dev/null +++ b/.github/workflows/branch_pr_actions.yaml @@ -0,0 +1,59 @@ +name: branch / PR action panel handler + +# Reacts to a maintainer ticking a box on the GenVM PR action panel (posted +# by branch_pr_checklist.yaml). The `dispatch` job authenticates the editor, +# parses the ticked boxes, performs label-based actions (run / rerun full +# tests), unticks the boxes, and reports whether Merge was requested. If so +# the `merge` job calls the reusable merge workflow. +# +# Resetting the panel re-fires issue_comment:edited, but that re-run finds +# no ticked box (and is sent by the bot), so it is a no-op — no loop. + +on: + issue_comment: + types: [edited] + +permissions: + contents: read + pull-requests: write + issues: write + actions: read + checks: read + +concurrency: + group: pr-actions-${{ github.event.issue.number }} + cancel-in-progress: false + +defaults: + run: + shell: bash -x {0} + +jobs: + dispatch: + # Only the panel comment, only on a PR. + if: > + github.event.issue.pull_request && + contains(github.event.comment.body, '') + runs-on: ubuntu-latest + outputs: + merge: ${{ steps.act.outputs.merge }} + steps: + # Checkout of the default branch only — to run the handler script. No + # PR code is executed. + - uses: actions/checkout@v4 + - name: Handle ticked boxes + id: act + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + SENDER: ${{ github.event.sender.login }} + run: python3 support/ci/pr-action-panel.py + + merge: + needs: dispatch + if: needs.dispatch.outputs.merge == 'true' + uses: ./.github/workflows/branch_merge_into_dev.yaml + with: + pr_number: ${{ github.event.issue.number }} + secrets: inherit diff --git a/.github/workflows/branch_pr_checklist.yaml b/.github/workflows/branch_pr_checklist.yaml new file mode 100644 index 000000000..331868789 --- /dev/null +++ b/.github/workflows/branch_pr_checklist.yaml @@ -0,0 +1,67 @@ +name: branch / post PR action panel + +# When a PR is opened against a dev branch (v-dev) we: +# - make sure the action labels exist; +# - post the action panel (checklist comment) that replaces the old +# /genvm-* slash commands; +# - auto-add `ci-safe` if the PR author has write access, so their PR can +# immediately use the panel. For everyone else a write-role maintainer +# adds `ci-safe` by hand once the PR is vetted. +# +# pull_request_target gives a write-scoped token even for fork PRs; we only +# call label/comment APIs and never check out or run PR code. The HTML +# marker lets branch_pr_actions.yaml recognise the panel later. + +on: + pull_request_target: + branches: ['v*-dev'] + types: [opened] + +permissions: + pull-requests: write + issues: write + +defaults: + run: + shell: bash -x {0} + +jobs: + post: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + steps: + - name: Ensure action labels exist + run: | + gh label create rtm -R "$GITHUB_REPOSITORY" --color 0e8a16 --description "ready to merge" 2>/dev/null || true + gh label create run-full-tests -R "$GITHUB_REPOSITORY" --color fbca04 --description "run full GenVM CI on every push" 2>/dev/null || true + gh label create ci-safe -R "$GITHUB_REPOSITORY" --color 5319e7 --description "PR cleared to run CI / panel actions" 2>/dev/null || true + + - name: Post action panel + run: | + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$(cat <<'EOF' + + ### GenVM PR actions + + Tick a box to run it (the box unticks itself when handled). Actions only run while the PR has the **`ci-safe`** label. + + - [ ] Force run full tests + - [ ] Rerun full tests + - [ ] Merge into dev + + Full GenVM CI runs only when **`rtm`** or **`run-full-tests`** is set — "Force run full tests" is a sticky toggle for `run-full-tests`. Adding **`rtm`** marks the PR ready-to-merge and also runs full tests. **Merge** requires: `rtm`, green full tests, green E2E, and the branch 0 commits behind. + EOF + )" + + - name: Auto-mark ci-safe for write-access authors + run: | + perm="$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$AUTHOR/permission" --jq '.permission' 2>/dev/null || echo none)" + case "$perm" in + admin|write|maintain) + echo "author $AUTHOR has '$perm' access; marking ci-safe" + gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR/labels" -f 'labels[]=ci-safe' ;; + *) + echo "author $AUTHOR has '$perm' access; leaving unmarked (a write-role maintainer must add ci-safe)" ;; + esac diff --git a/.github/workflows/branch_retarget.yaml b/.github/workflows/branch_retarget.yaml index 61ee2dcf9..a7f457150 100644 --- a/.github/workflows/branch_retarget.yaml +++ b/.github/workflows/branch_retarget.yaml @@ -44,6 +44,6 @@ jobs: gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body "$(cat <-dev) and is the authoritative -# "genvm CI" gate the /merge comment (branch_merge_into_dev.yaml) requires to be green on -# the exact head commit it fast-forwards. +# Full GenVM CI. The repo has NO GitHub merge queue: this is the +# authoritative "genvm CI" gate the Merge action +# (branch_merge_into_dev.yaml) requires to be green on the exact head it +# fast-forwards. +# +# For every push to a PR targeting a dev branch (v-dev): +# - `initial` (cheap pre-commit + 0-behind check) ALWAYS runs; +# - the heavy test jobs run only when a run-full-tests marker is set — +# the `rtm` (ready-to-merge) label or the `run-full-tests` label (the +# "Force run full tests" checkbox sets the latter); +# - without a marker `validate-end` fails, so the CI check stays red +# until a full run has happened. on: pull_request: branches: ['v*-dev'] - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, labeled] defaults: run: shell: bash -x {0} @@ -21,6 +29,7 @@ jobs: secrets: inherit module-test-python: + if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -36,6 +45,7 @@ jobs: - run: ./support/ci/pipelines/test-python.sh module-test-rust-fuzz: + if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -66,6 +76,7 @@ jobs: GEMINIKEY: ${{ secrets.GEMINIKEY }} module-test-rust: + if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -108,12 +119,25 @@ jobs: - module-test-python - module-test-rust - module-test-rust-fuzz + env: + HAS_MARKER: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} steps: - name: check run: | - results="${{ join(needs.*.result, ' ') }}" + # initial (pre-commit + 0-behind) must always pass. + if [ "${{ needs.initial.result }}" != "success" ]; then + echo "::error::initial checks failed" + exit 1 + fi + # No run-full-tests marker -> heavy tests were skipped; keep the + # gate red so the PR is not mergeable until they run. + if [ "$HAS_MARKER" != "true" ]; then + echo "::error::full tests have not run — add the 'rtm' label or check 'Force run full tests' on the action panel" + exit 1 + fi + results="${{ needs.module-test-python.result }} ${{ needs.module-test-rust.result }} ${{ needs.module-test-rust-fuzz.result }}" echo "$results" - if echo "$results" | grep -qiE '(failure|cancelled)'; then - echo "One or more jobs failed/cancelled" + if echo "$results" | grep -qiE '(failure|cancelled|skipped)'; then + echo "::error::one or more full-test jobs failed/cancelled/skipped" exit 1 fi diff --git a/support/ci/merge-into-dev.py b/support/ci/genvm-merge-into-dev.py similarity index 81% rename from support/ci/merge-into-dev.py rename to support/ci/genvm-merge-into-dev.py index 41884aaa9..c53b8ad5b 100755 --- a/support/ci/merge-into-dev.py +++ b/support/ci/genvm-merge-into-dev.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Gate and perform a `/merge` of a PR into a v-dev branch. +"""Gate and perform a Merge of a PR into a v-dev branch. -Invoked by .github/workflows/branch_merge_into_dev.yaml on a maintainer -`/merge` comment. It re-checks every gate against the EXACT PR head commit -and then advances the dev branch by a plain (fast-forward-only) push, so -what lands is byte-identical to what CI and E2E validated. +Invoked by the reusable .github/workflows/branch_merge_into_dev.yaml when a +maintainer ticks the "Merge" box on the PR action panel. It re-checks every +gate against the EXACT PR head commit and then advances the dev branch by a +plain (fast-forward-only) push, so what lands is byte-identical to what CI +and E2E validated. Gates (all required, all on the head commit): 1. base branch is a v-dev branch @@ -22,7 +23,7 @@ checks out with the GENVM_CI_PRIVATE_KEY deploy key and pushes non-force (a base that advanced is safely rejected). -Env: GITHUB_REPOSITORY, PR_NUMBER, GH_TOKEN, E2E_CHECK_PATTERN, COMMENT_ID. +Env: GITHUB_REPOSITORY, PR_NUMBER, GH_TOKEN, E2E_CHECK_PATTERN. """ import json @@ -34,7 +35,6 @@ REPO = os.environ['GITHUB_REPOSITORY'] PR = os.environ['PR_NUMBER'] E2E_PATTERN = os.environ.get('E2E_CHECK_PATTERN', 'e2e') -COMMENT_ID = os.environ.get('COMMENT_ID') def run(*args, check=True): @@ -59,7 +59,7 @@ def block(msg): '--repo', REPO, '--body', - f'❌ `/merge` blocked: {msg}', + f'❌ Merge blocked: {msg}', check=False, ) sys.exit(1) @@ -86,7 +86,10 @@ def check_gates(pr): head_sha = pr['headRefOid'] - # 3. full GenVM CI (queue.yaml) green on the head commit + # 3. full GenVM CI (queue.yaml) green on the head commit. queue.yaml runs + # on the `rtm` label, so the same head may have skipped runs (from + # unrelated labels) alongside the real one — require ANY completed run on + # this exact commit to have succeeded, not just the most recent. runs = json.loads( gh( 'api', @@ -94,10 +97,9 @@ def check_gates(pr): f'?head_sha={head_sha}&event=pull_request', ) )['workflow_runs'] - latest = runs[0] if runs else None - if not latest or latest['status'] != 'completed' or latest['conclusion'] != 'success': - got = 'no run' if not latest else f"{latest['status']} {latest['conclusion']}" - block(f'GenVM CI (queue.yaml) is not green on `{head_sha}` (got: {got}).') + if not any(r['status'] == 'completed' and r['conclusion'] == 'success' for r in runs): + got = ', '.join(f"{r['status']}/{r['conclusion']}" for r in runs) or 'no run' + block(f'GenVM CI (queue.yaml) is not green on `{head_sha}` (runs: {got}).') # 4. cross-repo E2E check green on the head commit checks = json.loads( @@ -130,7 +132,7 @@ def merge(pr, base, head_sha): fetched = git('rev-parse', 'refs/prhead').stdout.strip() if fetched != head_sha: block( - f'head moved during merge (expected `{head_sha}`, got `{fetched}`); re-run /merge.' + f'head moved during merge (expected `{head_sha}`, got `{fetched}`); re-tick Merge.' ) # 5. authoritative 0-commits-behind check at merge time. @@ -142,7 +144,7 @@ def merge(pr, base, head_sha): ): behind = git('rev-list', '--count', f'refs/prhead..origin/{base}').stdout.strip() block( - f'PR is {behind} commit(s) behind `{base}`; update the branch and re-run /merge.' + f'PR is {behind} commit(s) behind `{base}`; update the branch and re-tick Merge.' ) git('config', 'user.name', 'genvm-ci') @@ -164,7 +166,7 @@ def merge(pr, base, head_sha): if ( git('push', 'origin', f'{push_sha}:refs/heads/{base}', check=False).returncode != 0 ): - block(f'fast-forward push to `{base}` was rejected (base advanced); re-run /merge.') + block(f'fast-forward push to `{base}` was rejected (base advanced); re-tick Merge.') run( 'gh', @@ -181,18 +183,6 @@ def merge(pr, base, head_sha): def main(): - if COMMENT_ID: - run( - 'gh', - 'api', - '--method', - 'POST', - f'repos/{REPO}/issues/comments/{COMMENT_ID}/reactions', - '-f', - 'content=eyes', - check=False, - ) - pr = pr_view( 'baseRefName', 'headRefName', diff --git a/support/ci/pr-action-panel.py b/support/ci/pr-action-panel.py new file mode 100644 index 000000000..1fded4f7f --- /dev/null +++ b/support/ci/pr-action-panel.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Handle a ticked box on the GenVM PR action panel. + +Invoked by .github/workflows/branch_pr_actions.yaml on issue_comment:edited +when the edited comment is the panel (it carries the `` +marker). Steps: + +1. ignore edits made by a bot (the panel's own reset echo); +2. do nothing unless the PR carries the `ci-safe` label (the gate that lets +any of these actions run; auto-added for write-access authors); +3. parse which boxes are ticked and act: +- "Force run full tests" is a STICKY toggle: when ticked we just ensure the +`run-full-tests` label is set (so queue.yaml runs on every push). We neither +untick it nor trigger a one-off run — its checked state mirrors the label. +- "Rerun full tests" is a momentary button: re-apply `run-full-tests` +(remove+add) to force a fresh queue.yaml run on the current head, then untick. +- "Merge" is a momentary button: expose `merge=true` so the caller runs the +reusable merge workflow, then untick. +4. untick the momentary boxes (not the sticky Force one). + +Resetting the panel re-fires issue_comment:edited, but that event is sent by +the bot, so it is ignored — no loop. + +Env: GITHUB_REPOSITORY, PR_NUMBER, COMMENT_ID, SENDER, GH_TOKEN. +""" + +import os +import re +import subprocess + +REPO = os.environ['GITHUB_REPOSITORY'] +PR = os.environ['PR_NUMBER'] +COMMENT_ID = os.environ['COMMENT_ID'] +SENDER = os.environ['SENDER'] + +CI_SAFE_LABEL = 'ci-safe' +RUN_FULL_TESTS_LABEL = 'run-full-tests' + + +def run(*args, check=True): + return subprocess.run(args, check=check, text=True, capture_output=True) + + +def gh(*args, check=True): + return run('gh', *args, check=check) + + +def set_output(name, value): + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'{name}={value}\n') + + +def labels(): + out = gh('api', f'repos/{REPO}/issues/{PR}/labels', '--jq', '.[].name').stdout + return set(out.splitlines()) + + +def add_label(name): + gh( + 'api', + '--method', + 'POST', + f'repos/{REPO}/issues/{PR}/labels', + '-f', + f'labels[]={name}', + ) + + +def remove_label(name): + gh( + 'api', '--method', 'DELETE', f'repos/{REPO}/issues/{PR}/labels/{name}', check=False + ) + + +def ticked_boxes(body): + return [ + m.group(1).strip().lower() + for m in re.finditer(r'(?m)^\s*-\s*\[[xX]\]\s*(.+?)\s*$', body) + ] + + +def untick_momentary(body): + # Untick every ticked box EXCEPT the sticky "Force run full tests" one. + def reset(line): + if re.match(r'\s*-\s*\[[xX]\]', line) and 'force' not in line.lower(): + return re.sub(r'\[[xX]\]', '[ ]', line, count=1) + return line + + new = '\n'.join(reset(line) for line in body.splitlines()) + if body.endswith('\n'): + new += '\n' + if new != body: + gh( + 'api', + '--method', + 'PATCH', + f'repos/{REPO}/issues/comments/{COMMENT_ID}', + '-f', + f'body={new}', + ) + + +def main(): + set_output('merge', 'false') + + # Ignore the bot's own panel-reset edit (avoids a self-trigger loop). + if SENDER.endswith('[bot]'): + print(f'{SENDER} is a bot (panel reset echo); ignoring') + return + + current = labels() + if CI_SAFE_LABEL not in current: + print(f'PR lacks the `{CI_SAFE_LABEL}` label; ignoring panel actions') + return + + body = gh('api', f'repos/{REPO}/issues/comments/{COMMENT_ID}', '--jq', '.body').stdout + boxes = ticked_boxes(body) + if not boxes: + print('no ticked boxes; nothing to do') + return + + # Force: sticky enable of the run-full-tests marker (no untick, no one-off). + if any('force' in b for b in boxes) and RUN_FULL_TESTS_LABEL not in current: + add_label(RUN_FULL_TESTS_LABEL) + + # Rerun: force a fresh queue run on the current head even if the marker is + # already set (remove+add re-emits the `labeled` event). + if any('rerun' in b for b in boxes): + remove_label(RUN_FULL_TESTS_LABEL) + add_label(RUN_FULL_TESTS_LABEL) + + if any('merge' in b for b in boxes): + set_output('merge', 'true') + + untick_momentary(body) + + +if __name__ == '__main__': + main() From abb71bf891695b737e6a4f5211f4740a3b25543d Mon Sep 17 00:00:00 2001 From: kp2pml30 Date: Thu, 25 Jun 2026 22:54:13 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(ci):=20dispatch=20full=20tests=20direct?= =?UTF-8?q?ly=20from=20the=20PR=20action=20panel=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Force"/"Rerun full tests" boxes set the `run-full-tests` label with the bot's GITHUB_TOKEN, but GitHub suppresses the resulting `labeled` event (recursive-run guard), so queue.yaml never fired and the buttons silently did nothing. Trigger queue.yaml directly via `workflow_dispatch` instead, which DOES run when fired by GITHUB_TOKEN. The handler dispatches on the PR head branch so the run's head_sha equals the PR head and the Merge gate counts it. Force still sets the sticky label (future pushes auto-run) and now also dispatches once on the enabling edit; Rerun just dispatches. - queue.yaml: add `workflow_dispatch`, run on it, mark its run-name - branch_pr_actions.yaml: grant `actions: write` to dispatch - pr-action-panel.py: replace label-toggle with `gh workflow run` - genvm-merge-into-dev.py: count workflow_dispatch runs on the head sha --- .github/workflows/branch_pr_actions.yaml | 2 +- .github/workflows/queue.yaml | 25 ++++++++++-- support/ci/genvm-merge-into-dev.py | 13 +++--- support/ci/pr-action-panel.py | 51 +++++++++++++++++------- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/.github/workflows/branch_pr_actions.yaml b/.github/workflows/branch_pr_actions.yaml index b6087e728..4d11a25c2 100644 --- a/.github/workflows/branch_pr_actions.yaml +++ b/.github/workflows/branch_pr_actions.yaml @@ -17,7 +17,7 @@ permissions: contents: read pull-requests: write issues: write - actions: read + actions: write checks: read concurrency: diff --git a/.github/workflows/queue.yaml b/.github/workflows/queue.yaml index 9e0b8537b..ec0c5714c 100644 --- a/.github/workflows/queue.yaml +++ b/.github/workflows/queue.yaml @@ -12,10 +12,27 @@ name: GenVM full # "Force run full tests" checkbox sets the latter); # - without a marker `validate-end` fails, so the CI check stays red # until a full run has happened. +# +# The action panel ("Force"/"Rerun full tests") triggers a run directly via +# `workflow_dispatch` rather than relying on a label edit: a label applied by +# the bot's GITHUB_TOKEN does NOT emit a `labeled` event (GitHub suppresses +# recursive runs), but a `workflow_dispatch` from that same token DOES run. +# The dispatch targets the PR head branch, so the run's head_sha equals the +# PR head and the Merge gate (which matches by head_sha) counts it. on: pull_request: branches: ['v*-dev'] types: [opened, synchronize, reopened, ready_for_review, labeled] + workflow_dispatch: + inputs: + pr: + description: PR number this manual run validates (used only for the run name) + type: string + required: false + +run-name: >- + GenVM full${{ github.event_name == 'workflow_dispatch' && format(' (manual, PR #{0})', inputs.pr) || '' }} + defaults: run: shell: bash -x {0} @@ -29,7 +46,7 @@ jobs: secrets: inherit module-test-python: - if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -45,7 +62,7 @@ jobs: - run: ./support/ci/pipelines/test-python.sh module-test-rust-fuzz: - if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -76,7 +93,7 @@ jobs: GEMINIKEY: ${{ secrets.GEMINIKEY }} module-test-rust: - if: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} needs: [initial] runs-on: ubuntu-latest steps: @@ -120,7 +137,7 @@ jobs: - module-test-rust - module-test-rust-fuzz env: - HAS_MARKER: ${{ contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} + HAS_MARKER: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'rtm') || contains(github.event.pull_request.labels.*.name, 'run-full-tests') }} steps: - name: check run: | diff --git a/support/ci/genvm-merge-into-dev.py b/support/ci/genvm-merge-into-dev.py index c53b8ad5b..c9960e09c 100755 --- a/support/ci/genvm-merge-into-dev.py +++ b/support/ci/genvm-merge-into-dev.py @@ -86,15 +86,16 @@ def check_gates(pr): head_sha = pr['headRefOid'] - # 3. full GenVM CI (queue.yaml) green on the head commit. queue.yaml runs - # on the `rtm` label, so the same head may have skipped runs (from - # unrelated labels) alongside the real one — require ANY completed run on - # this exact commit to have succeeded, not just the most recent. + # 3. full GenVM CI (queue.yaml) green on the head commit. The same head may + # carry skipped runs (label events that didn't run full tests) alongside the + # real one, and the run may have been started by either a push + # (event=pull_request) or the action panel (event=workflow_dispatch) — so we + # query all events and require ANY completed run on this exact commit to have + # succeeded, not just the most recent. runs = json.loads( gh( 'api', - f'repos/{REPO}/actions/workflows/queue.yaml/runs' - f'?head_sha={head_sha}&event=pull_request', + f'repos/{REPO}/actions/workflows/queue.yaml/runs?head_sha={head_sha}', ) )['workflow_runs'] if not any(r['status'] == 'completed' and r['conclusion'] == 'success' for r in runs): diff --git a/support/ci/pr-action-panel.py b/support/ci/pr-action-panel.py index 1fded4f7f..bd5d43cf4 100644 --- a/support/ci/pr-action-panel.py +++ b/support/ci/pr-action-panel.py @@ -9,15 +9,24 @@ 2. do nothing unless the PR carries the `ci-safe` label (the gate that lets any of these actions run; auto-added for write-access authors); 3. parse which boxes are ticked and act: -- "Force run full tests" is a STICKY toggle: when ticked we just ensure the -`run-full-tests` label is set (so queue.yaml runs on every push). We neither -untick it nor trigger a one-off run — its checked state mirrors the label. -- "Rerun full tests" is a momentary button: re-apply `run-full-tests` -(remove+add) to force a fresh queue.yaml run on the current head, then untick. +- "Force run full tests" is a STICKY toggle: on the edit that newly ticks it +we set the `run-full-tests` label (so queue.yaml runs on every future push) +AND dispatch one run now. We do not untick it — its checked state mirrors the +label, and an already-set label means no re-dispatch on unrelated edits. +- "Rerun full tests" is a momentary button: dispatch a fresh queue.yaml run on +the current head, then untick. - "Merge" is a momentary button: expose `merge=true` so the caller runs the reusable merge workflow, then untick. 4. untick the momentary boxes (not the sticky Force one). +Runs are started with `gh workflow run queue.yaml --ref ` (a +`workflow_dispatch`), NOT by toggling a label: a label applied by the bot's +GITHUB_TOKEN does not emit a `labeled` event (GitHub blocks recursive runs), +whereas a `workflow_dispatch` from the same token does run. Dispatching on the +PR head branch makes the run's head_sha equal the PR head, so the Merge gate +counts it. (Fork PRs have no head branch in this repo, so the panel can only +dispatch for same-repo branches — the common GenVM flow.) + Resetting the panel re-fires issue_comment:edited, but that event is sent by the bot, so it is ignored — no loop. @@ -66,10 +75,22 @@ def add_label(name): ) -def remove_label(name): - gh( - 'api', '--method', 'DELETE', f'repos/{REPO}/issues/{PR}/labels/{name}', check=False - ) +def head_branch(): + return gh( + 'pr', 'view', PR, '--repo', REPO, '--json', 'headRefName', '--jq', '.headRefName' + ).stdout.strip() + + +def dispatch_full_tests(): + # Start queue.yaml on the PR head branch so the run's head_sha matches the + # PR head (the Merge gate keys off head_sha). A workflow_dispatch fired with + # GITHUB_TOKEN does create a run, unlike a bot-applied `labeled` event. + branch = head_branch() + if not branch: + print('could not resolve PR head branch; cannot dispatch full tests') + return + gh('workflow', 'run', 'queue.yaml', '--repo', REPO, '--ref', branch, '-f', f'pr={PR}') + print(f'dispatched queue.yaml on `{branch}`') def ticked_boxes(body): @@ -119,15 +140,17 @@ def main(): print('no ticked boxes; nothing to do') return - # Force: sticky enable of the run-full-tests marker (no untick, no one-off). + # Force: sticky enable of the run-full-tests marker so every future push + # runs full tests. Only on the edit that newly sets it do we also dispatch + # a run now (an already-set label means this is an unrelated edit -> no + # duplicate run). if any('force' in b for b in boxes) and RUN_FULL_TESTS_LABEL not in current: add_label(RUN_FULL_TESTS_LABEL) + dispatch_full_tests() - # Rerun: force a fresh queue run on the current head even if the marker is - # already set (remove+add re-emits the `labeled` event). + # Rerun: momentary -> always dispatch a fresh run on the current head. if any('rerun' in b for b in boxes): - remove_label(RUN_FULL_TESTS_LABEL) - add_label(RUN_FULL_TESTS_LABEL) + dispatch_full_tests() if any('merge' in b for b in boxes): set_output('merge', 'true')