Skip to content

ci: add dispatch-driven release pipeline (bump → tag → build)#19

Merged
bdimitrov-netzine merged 3 commits into
mainfrom
ci/release-pipeline
Jul 7, 2026
Merged

ci: add dispatch-driven release pipeline (bump → tag → build)#19
bdimitrov-netzine merged 3 commits into
mainfrom
ci/release-pipeline

Conversation

@bdimitrov-netzine

Copy link
Copy Markdown
Contributor

Semver release automation. Cargo.toml ([workspace.package].version, inherited by all crates) is the single source of truth; the tag is derived from it. Gated to named release managers.

Flow

Actions ▸ Release ▸ run {bump: patch|minor|major}   (only RELEASE_ACTORS)
        │
        ▼  release.yml
bump [workspace.package].version + sync Cargo.lock
open PR "chore(release): vX" (assigned to you)
        │  (you review + merge; main is branch-protected)
        ▼  tag-on-release.yml  (push: main, paths: Cargo.toml)
version changed?  ──► create + push tag vX
        │
        ▼  build-docker.yml (workflow_call)
build & push devnet-vX to ECR  (binary version == tag, guaranteed)

Files

  • release.ymlworkflow_dispatch with bump: patch|minor|major. Computes the next version (prerelease/build suffixes stripped), rewrites only the [workspace.package] version line (members keep version.workspace = true), runs cargo update --workspace to sync Cargo.lock (leaves third-party deps alone), and opens the release PR assigned to you.
  • tag-on-release.yml — on push: main touching Cargo.toml, tags vX only if the workspace version actually changed and the tag doesn't already exist, then builds the devnet image.
  • build-docker.yml — meta step now keys the tag off INPUT_TAG rather than github.event_name (so a workflow_call from a push-triggered caller resolves correctly instead of mis-deriving from a branch ref), plus a guard that a hand-pushed v* tag must match the committed version.

Access gating (per your request)

The bump job runs only if github.actor is in the RELEASE_ACTORS repo variable (already set to ["bdimitrov-netzine"]). workflow_dispatch already needs write access; this narrows it to you, and fails closed if the variable is ever unset. Merging the release PR is separately gated by branch protection (required review).

Why workflow_call instead of the tag push triggering the build

A tag created with the default GITHUB_TOKEN does not trigger other workflows (GitHub anti-recursion), so build-docker.yml's on: push: tags would never fire for an auto-created tag. Calling it directly avoids needing a PAT/App token.

Notes / how it matches Solana's agave

The bump-PR half mirrors agave's bump-version.yml (dispatch + level → bump Cargo.toml → open PR assigned to triggerer). Differences: agave stops at the PR (no tag/build; tagging is separate) and gates by org (repository_owner), not by user. Ours chains through to tag+build and gates by actor.

Two constraints to keep in mind: builds only ever produce devnet images (testnet/mainnet still go through promote-docker.yml's approval gate); and the version is compiled into block extradata (rayls-network/v<ver>/<os>, ≤32 bytes) so avoid long prerelease suffixes.

Validated: actionlint clean; bump logic unit-checked for patch/minor/major + prerelease-strip; awk edit confirmed to touch only the single [workspace.package] version line.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bdimitrov-netzine's task in 2m 39s —— View job


PR Review: ci/release-pipeline

Tasks

  • Read changed files
  • Analyze for bugs, security issues, and correctness
  • Post review

Overall this is a well-structured pipeline. The anti-recursion rationale is sound, all actions are SHA-pinned, shell scripts use set -euo pipefail, and the post-bump verification step in release.yml is good discipline. A few issues to address, ordered by severity:


Medium — Silent || true risks spurious tagging

tag-on-release.yml:40

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}' || true)

The 2>/dev/null already handles the "file doesn't exist at HEAD~1" case silently. The trailing || true additionally suppresses any awk failure or pipeline write error, leaving OLD as an empty string. Since the condition on line 42 is [ "$NEW" != "$OLD" ], an empty OLD always satisfies it, meaning any infrastructure hiccup (repo object store issue, transient runner error) could cause the workflow to create an unwanted tag.

Fix: Drop the || true; awk produces empty output on empty input and doesn't exit non-zero. If you want to be explicit about the "new repo / first commit" case, handle it after:

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}')
OLD="${OLD:-}"  # empty on initial commit — safe, will trigger tagging once

Fix this →


Medium — Duplicate trigger: second dispatch fails mid-run

release.yml:100

If workflow_dispatch is triggered twice before the first PR is merged (e.g., triggered in error and immediately re-run), both runs compute the same NEW version and race to git push origin release/v$NEW. The second push will fail with a non-zero exit (branch already exists), producing a confusing job failure rather than a clean "nothing to do."

A branch existence pre-check keeps the error message meaningful:

if git ls-remote --exit-code --heads origin "release/v$NEW" >/dev/null 2>&1; then
  echo "::warning::Branch release/v$NEW already exists; skipping."
  exit 0
fi

Or alternatively, add a gh pr list --head "release/v$NEW" check and skip if a PR is already open.

Fix this →


Low — cargo update --workspace can bump transitive deps

release.yml:77

The PR description says this "leaves third-party deps alone," but cargo update --workspace updates all workspace-member package entries in Cargo.lock and may resolve newer compatible versions of their transitive dependencies if semver allows it. This won't cause a build failure, but it means the release PR's diff can include unexpected dep version churns.

If you want strictly version-only changes to Cargo.lock, the alternative is:

# Only update version entries for workspace members (no dep resolution churn)
cargo metadata --format-version 1 \
  | jq -r '.workspace_members[] | split(" ") | .[0]' \
  | xargs -I{} cargo update -p "{}"

This is a minor risk — the behaviour is documented and acceptable — but worth being explicit about in the comment or PR body.


Low — RELEASE_ACTORS gate applies only to the automation path

release.yml:34 and tag-on-release.yml

The if: contains(fromJSON(vars.RELEASE_ACTORS || '[]'), github.actor) check correctly gates the bump PR creation. However, tag-on-release.yml triggers on any push: main that touches Cargo.toml, with no actor check. Any collaborator with merge-to-main rights can trigger a tag by manually editing [workspace.package].version and merging.

This is noted in the flow description, but it's worth documenting explicitly in tag-on-release.yml's header comment so the security model is unambiguous for future maintainers.


Low — workflow_dispatch inputs missing type: in build-docker.yml

build-docker.yml:26–31

The workflow_call inputs declare type: string (lines 37, 42) but the corresponding workflow_dispatch inputs don't. Functionally equivalent (defaults to string), but inconsistent and may confuse tools that validate input schemas:

workflow_dispatch:
  inputs:
    tag:
      description: 'Image tag to push (e.g., devnet-v1.0.0 or dev-manual)'
      required: true
      type: string   # add this
    ref:
      description: 'Git ref/branch/SHA to build from (default: this workflow ref)'
      required: false
      type: string   # add this
      default: ''

Fix this →


Confirmed Correct

  • Rust toolchain 1.91.0 matches rust-toolchain.toml
  • fromJSON(vars.RELEASE_ACTORS || '[]') correctly fails closed when the variable is unset ✓
  • fetch-depth: 2 in tag-on-release.yml for the HEAD~1 diff ✓
  • Tag existence guard (git rev-parse -q --verify) prevents duplicate tags ✓
  • Anti-recursion workaround (workflow_call instead of relying on push: tags) is correct and well-documented ✓
  • The INPUT_TAG keying change in build-docker.yml correctly handles the event_name == 'push' from the caller context ✓
  • Trivy scan is blocking (exit-code: '1') on HIGH/CRITICAL ✓

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Security Scan - Code

Severity: HIGH, CRITICAL

No vulnerabilities found

View scan results

Report Summary

┌─────────────────────────────────────────────┬────────────┬─────────────────┬───────────────────┐
│                   Target                    │    Type    │ Vulnerabilities │ Misconfigurations │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ Cargo.lock                                  │   cargo    │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ crates/testing/fuzz-targets/Cargo.lock      │   cargo    │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ crates/testing/fuzz-targets/fuzz/Cargo.lock │   cargo    │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ etc/state-sum/Cargo.lock                    │   cargo    │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ etc/tps/package-lock.json                   │    npm     │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ rayls-contracts/package-lock.json           │    npm     │        0        │         -         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ etc/chaos-network/Dockerfile                │ dockerfile │        -        │         0         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ etc/docker-network/Dockerfile               │ dockerfile │        -        │         0         │
├─────────────────────────────────────────────┼────────────┼─────────────────┼───────────────────┤
│ etc/docker-replay/Dockerfile                │ dockerfile │        -        │         0         │
└─────────────────────────────────────────────┴────────────┴─────────────────┴───────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bdimitrov-netzine's task in 2m 17s —— View job


PR Review: ci/release-pipeline (re-review after sync)

Tasks

  • Read changed files
  • Analyze for bugs, security issues, and correctness
  • Post review

What's new in this sync

The latest commit (1bcc5eb) adds github.repository_owner == 'raylsnetwork' as a fork guard on the release job (release.yml:37). The implementation is correct:

if: >
  github.repository_owner == 'raylsnetwork' &&
  contains(fromJSON(vars.RELEASE_ACTORS || '[]'), github.actor)

The || '[]' fallback still fails closed when RELEASE_ACTORS is unset, and the fork guard is orthogonal to the actor gate — both are required. The commit message is also clear about the distinction between "fork guard" and "org membership check." This change is correct.


Prior review items — still open

The four items from the earlier automated review remain unaddressed. Recapping with current line numbers:

Medium — || true silently swallows pipeline failures in tag-on-release.yml:40

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}' || true)

With set -euo pipefail, the || true is partly load-bearing (absorbs the git show exit-128 on an initial commit), but it also silences any awk error, leaving OLD="". An empty OLD satisfies [ "$NEW" != "$OLD" ] unconditionally, which could create an unintended tag on a transient runner failure.

Cleaner fix that keeps the intent explicit:

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}') || OLD=""

|| OLD="" still handles the first-commit case (git exits 128 → pipeline fails → OLD=""), but now awk failures propagate rather than being swallowed.

Fix this →%20%7C%7C%20OLD%3D%22%22%60%20so%20the%20first-commit%20case%20is%20handled%20but%20awk%20failures%20still%20propagate.%20Branch%20is%20ci%2Frelease-pipeline.&repo=raylsnetwork/axyl)


Medium — Double-dispatch race in release.yml:98–104

If triggered twice before the first PR is merged, both runs compute the same $NEW, and the second git push --set-upstream origin "$BRANCH" fails with a non-zero exit. Under set -euo pipefail the job dies with a confusing "failed to push refs" error rather than a clean "already in progress" notice.

A branch existence pre-check produces a much clearer signal:

if git ls-remote --exit-code --heads origin "release/v$NEW" >/dev/null 2>&1; then
  echo "::warning::Branch release/v$NEW already exists — skipping duplicate dispatch."
  exit 0
fi

Fix this →


Low — workflow_dispatch inputs missing type: string in build-docker.yml:26–31

The workflow_call inputs declare type: string (lines 37, 42) but the matching workflow_dispatch inputs don't. Functionally identical but inconsistent — schema validators and IDE tooling may flag it.

workflow_dispatch:
  inputs:
    tag:
      description: 'Image tag to push (e.g., devnet-v1.0.0 or dev-manual)'
      required: true
      type: string   # add
    ref:
      description: 'Git ref/branch/SHA to build from (default: this workflow ref)'
      required: false
      type: string   # add
      default: ''

Fix this →


Low — No actor gate on tag-on-release.yml

tag-on-release.yml fires on any push: main that touches Cargo.toml regardless of who merged. Any collaborator with merge-to-main rights can trigger a tag. This is by design (branch protection is the outer gate), but a comment in the workflow header would make the security model explicit for future maintainers.


Still confirmed correct

  • Rust toolchain 1.91.0 pin in release.yml
  • All action references SHA-pinned ✓
  • fromJSON(vars.RELEASE_ACTORS || '[]') fails closed when unset ✓
  • fetch-depth: 2 ensures HEAD~1 is available in tag-on-release.yml
  • Tag existence guard (git rev-parse -q --verify) prevents duplicate tags ✓
  • INPUT_TAG keying fix in build-docker.yml correctly handles workflow_call from a push-triggered caller ✓
  • Fork guard addition (repository_owner == 'raylsnetwork') in 1bcc5eb is correct ✓

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bdimitrov-netzine's task in 1m 25s —— View job


PR Review: ci/release-pipeline (re-review — commit 79a3e7f)

Tasks

  • Read changed files
  • Analyze changes (latest commit: 79a3e7f)
  • Post review

What changed in this sync

The latest commit (79a3e7f) drops the RELEASE_ACTORS allowlist entirely and replaces the two-condition gate with a single fork guard:

# Before (1bcc5eb)
if: >
  github.repository_owner == 'raylsnetwork' &&
  contains(fromJSON(vars.RELEASE_ACTORS || '[]'), github.actor)

# After (79a3e7f)
if: github.repository_owner == 'raylsnetwork'

The implementation is correct and the commit message is transparent about the tradeoff. The header comment in release.yml now accurately documents the access model and explicitly notes that strict org-membership gating (excluding outside collaborators) would require a read:org PAT. No implementation bug here.


Low — Outside collaborators with write can now cut releases

workflow_dispatch requires repository write access, not org membership. This includes outside collaborators (Settings → Collaborators → write). After this change, any outside collaborator with write can trigger a release bump — the old allowlist was surgical enough to exclude them even if they had write for other reasons (e.g., reviewing code or merging hotfixes).

This is a deliberate policy call, not a bug. Whether it's the right call depends on your collaborator roster. If you currently have or expect to have outside collaborators with write access who shouldn't be able to cut releases, the allowlist approach from 1bcc5eb was more appropriate.

No change needed if the collaborator set is tightly controlled, but worth confirming: do any outside collaborators in raylsnetwork/axyl have write access?


Medium — || true still silently swallows failures in tag-on-release.yml:40

Unchanged from prior reviews:

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}' || true)

The || true handles the first-commit case (git exits 128), but also silences any awk error, leaving OLD="". An empty OLD always satisfies [ "$NEW" != "$OLD" ], which could create an unintended tag on a transient runner failure. The fix keeps the first-commit case explicit:

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}') || OLD=""

Fix this →


Medium — Double-dispatch race in release.yml:101–104

Unchanged. If triggered twice before the first PR is merged, both runs compute the same $NEW and race on git push --set-upstream origin "$BRANCH". The second push exits non-zero under set -euo pipefail, producing a confusing failure. A pre-check avoids this:

if git ls-remote --exit-code --heads origin "release/v$NEW" >/dev/null 2>&1; then
  echo "::warning::Branch release/v$NEW already exists — skipping duplicate dispatch."
  exit 0
fi

Fix this →


Low — workflow_dispatch inputs missing type: string in build-docker.yml:26–31

Unchanged. The workflow_call block declares type: string for both inputs; the workflow_dispatch block does not. Functionally identical, but inconsistent and may confuse schema validators or IDE tooling.

Fix this →


Still confirmed correct

  • Fork guard change (repository_owner == 'raylsnetwork' only) is correctly implemented ✓
  • Comment in release.yml header accurately documents the new access model and its limits ✓
  • All prior items confirmed correct in 1bcc5eb remain correct ✓
  • Action SHA pins unchanged ✓
  • fromJSON(vars.RELEASE_ACTORS || '[]') guard removed cleanly — no dangling references ✓

Adds semver release automation, gated to named release managers.

release.yml (workflow_dispatch, input: patch/minor/major):
  bumps [workspace.package].version (all crates inherit it), syncs
  Cargo.lock, and opens a `chore(release): vX` PR assigned to the
  triggerer. Gated by `contains(fromJSON(vars.RELEASE_ACTORS||'[]'),
  github.actor)` — fails closed if the var is unset. PR-based because
  main is branch-protected.

tag-on-release.yml (push: main, paths: Cargo.toml):
  when [workspace.package].version changes on main, creates tag vX and
  builds the devnet image. Calls build-docker.yml via workflow_call
  rather than the tag push, because a GITHUB_TOKEN-created tag does not
  trigger other workflows (GitHub anti-recursion).

build-docker.yml:
  the meta step now keys the tag off INPUT_TAG instead of
  github.event_name, so a workflow_call from a push-triggered caller
  resolves correctly (previously it would mis-derive the tag from a
  branch ref). Adds a guard: a manually pushed v* tag must match the
  committed workspace version, else the build fails fast.

Cargo.toml is the single source of truth; the tag is derived from it.
Also require github.repository_owner == 'raylsnetwork' on the release
job, so the workflow can only cut a release from the canonical org repo,
never a fork — in addition to the existing RELEASE_ACTORS actor gate.
This is a fork guard, not an org-membership check.
Broaden who can cut a release from a named allowlist to any collaborator
/ org member with write access to the canonical repo. workflow_dispatch
already requires write access, so the job now only pins
repository_owner == 'raylsnetwork' as a fork guard and drops the
RELEASE_ACTORS check (variable removed).

A strict org-membership assertion (excluding outside collaborators)
would require a read:org PAT, which the default GITHUB_TOKEN can't do;
left out to avoid a new secret.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bdimitrov-netzine's task in 1m 42s —— View job


PR Review: ci/release-pipeline (re-review — commit 4942dea)

Tasks

  • Read changed files
  • Analyze latest commit(s) since 79a3e7f
  • Post review

What changed in this sync

The branch was force-pushed; commits 1bcc5eb and 79a3e7f from prior reviews have been rewritten into 1a58062 and 4942dea respectively. The effective diff is identical to what 79a3e7f introduced — RELEASE_ACTORS dropped, the access gate simplified to the fork guard alone:

# Before (1a58062)
if: >
  github.repository_owner == 'raylsnetwork' &&
  contains(fromJSON(vars.RELEASE_ACTORS || '[]'), github.actor)

# After (4942dea)
if: github.repository_owner == 'raylsnetwork'

The header comment in release.yml now accurately documents the new access model and its constraints (outside collaborators, read:org PAT limitation). This change is correct and the commit message is transparent about the tradeoff.


Open items — three issues remain unaddressed

Medium — || true silently swallows failures (tag-on-release.yml:40)

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}' || true)

The || true handles the first-commit case (git exits 128), but also silences any awk error, leaving OLD="". Under set -euo pipefail, an empty OLD always satisfies [ "$NEW" != "$OLD" ], so a transient runner failure (awk OOM, broken pipe) could create an unintended tag. Move the || fallback outside the command substitution so awk failures still propagate:

OLD=$(git show HEAD~1:Cargo.toml 2>/dev/null \
      | awk -F'"' '/^\[workspace\.package\]/{f=1} f&&/^version = "/{print $2; exit}') || OLD=""

Fix this →


Medium — Double-dispatch race (release.yml:101–104)

If workflow_dispatch is triggered twice before the first PR is merged (accidental double-click, retry), both runs compute the same $NEW and race on:

git push --set-upstream origin "$BRANCH"

The second push fails non-zero under set -euo pipefail, producing a confusing "failed to push refs" error rather than a clean "already in progress" notice. A pre-check before git switch -c avoids this:

if git ls-remote --exit-code --heads origin "release/v$NEW" >/dev/null 2>&1; then
  echo "::warning::Branch release/v$NEW already exists — skipping duplicate dispatch."
  exit 0
fi

Fix this →


Low — workflow_dispatch inputs missing type: string (build-docker.yml:26–31)

The workflow_call block declares type: string for both inputs (lines 37, 42), but the workflow_dispatch block does not. Functionally identical, but inconsistent — schema validators and IDE tooling may flag it:

workflow_dispatch:
  inputs:
    tag:
      description: 'Image tag to push (e.g., devnet-v1.0.0 or dev-manual)'
      required: true
      type: string   # add
    ref:
      description: 'Git ref/branch/SHA to build from (default: this workflow ref)'
      required: false
      type: string   # add
      default: ''

Fix this →


Still confirmed correct

  • Fork guard change (repository_owner == 'raylsnetwork' only) correctly implemented ✓
  • Header comment in release.yml accurately documents the new access model and its limits ✓
  • fromJSON(vars.RELEASE_ACTORS || '[]') guard removed cleanly — no dangling references ✓
  • All action references SHA-pinned ✓
  • fetch-depth: 2 ensures HEAD~1 is available in tag-on-release.yml
  • Tag existence guard (git rev-parse -q --verify) prevents duplicate tags ✓
  • INPUT_TAG keying fix in build-docker.yml correctly handles workflow_call from push-triggered caller ✓
  • Rust toolchain 1.91.0 pin ✓

@bdimitrov-netzine bdimitrov-netzine merged commit a36486a into main Jul 7, 2026
6 checks passed
@bdimitrov-netzine bdimitrov-netzine deleted the ci/release-pipeline branch July 7, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant