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_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/.github/workflows/branch_merge_into_dev.yaml b/.github/workflows/branch_merge_into_dev.yaml new file mode 100644 index 000000000..7f6fd8a5e --- /dev/null +++ b/.github/workflows/branch_merge_into_dev.yaml @@ -0,0 +1,69 @@ +name: branch / merge PR into dev + +# 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 +# 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. The caller is +# responsible for restricting who can trigger a merge (maintainers only). + +on: + workflow_call: + inputs: + pr_number: + description: number of the PR to merge + type: string + required: true + +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: + 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: ${{ 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..4d11a25c2 --- /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: write + 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_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/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): +# - `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. +# +# 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: - merge_group: + 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} @@ -14,6 +46,7 @@ jobs: secrets: inherit module-test-python: + 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: @@ -29,6 +62,7 @@ jobs: - run: ./support/ci/pipelines/test-python.sh module-test-rust-fuzz: + 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: @@ -59,6 +93,7 @@ jobs: GEMINIKEY: ${{ secrets.GEMINIKEY }} module-test-rust: + 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: @@ -101,12 +136,25 @@ jobs: - module-test-python - module-test-rust - module-test-rust-fuzz + env: + 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: | - 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/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}] 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`. diff --git a/support/ci/genvm-merge-into-dev.py b/support/ci/genvm-merge-into-dev.py new file mode 100755 index 000000000..c9960e09c --- /dev/null +++ b/support/ci/genvm-merge-into-dev.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Gate and perform a Merge of a PR into a v-dev branch. + +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 +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. +""" + +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') + + +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. 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?head_sha={head_sha}', + ) + )['workflow_runs'] + 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( + 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-tick 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-tick 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-tick 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(): + 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() diff --git a/support/ci/pr-action-panel.py b/support/ci/pr-action-panel.py new file mode 100644 index 000000000..bd5d43cf4 --- /dev/null +++ b/support/ci/pr-action-panel.py @@ -0,0 +1,162 @@ +#!/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: 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. + +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 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): + 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 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: momentary -> always dispatch a fresh run on the current head. + if any('rerun' in b for b in boxes): + dispatch_full_tests() + + if any('merge' in b for b in boxes): + set_output('merge', 'true') + + untick_momentary(body) + + +if __name__ == '__main__': + main()