From 820b67f4679835df88c67c9d7322d22da24d6bbf Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 14:49:34 +0000 Subject: [PATCH 1/9] introduce: initial scenarios --- .github/workflows/ci.yml | 64 +++++++++++++- scenario/lib.sh | 112 ++++++++++++++++++++++++ scenario/order.sh | 14 +++ scenario/run.sh | 149 ++++++++++++++++++++++++++++++++ scenario/test_basic_balances.sh | 43 +++++++++ scenario/test_containers.sh | 45 ++++++++++ 6 files changed, 426 insertions(+), 1 deletion(-) create mode 100755 scenario/lib.sh create mode 100755 scenario/order.sh create mode 100755 scenario/run.sh create mode 100755 scenario/test_basic_balances.sh create mode 100755 scenario/test_containers.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a82ffc..63a5fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,19 @@ on: branches: ['*'] pull_request: branches: [main] + schedule: + # Nightly at 03:00 UTC + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + reporting: + description: 'Create GitHub issue with scenario report' + type: boolean + default: false + skip_report_on_pass: + description: 'Skip filing issue when all scenarios pass' + type: boolean + default: true jobs: fmt-clippy: @@ -26,9 +39,38 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install shfmt + run: sudo snap install shfmt + + # Ensure all .sh files under scenario/ are executable + - name: Check executable permissions + run: | + bad=$(find scenario -name '*.sh' ! -perm -u+x) + if [ -n "$bad" ]; then + echo "Missing +x on:" >&2; echo "$bad" >&2; exit 1 + fi + + # Lint with shellcheck + - name: shellcheck + run: find scenario -name '*.sh' -exec shellcheck -S warning {} + + + # Verify consistent formatting (indent=2, binary ops start of line) + - name: shfmt + run: shfmt -d -i 2 -bn scenario/ + foc-start-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] - timeout-minutes: 60 + timeout-minutes: 100 + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v4 @@ -346,6 +388,26 @@ jobs: node check-balances.js "$DEVNET_INFO" echo "✓ All examples ran well" + # Run scenario tests against the live devnet + - name: "TEST: {Run scenario tests}" + if: steps.start_cluster.outcome == 'success' + env: + # Enable reporting for nightly schedule or when explicitly requested + REPORTING: ${{ github.event_name == 'schedule' || inputs.reporting == true }} + # By default, don't file an issue if everything passes + SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scenario/run.sh + + # Upload scenario report as artifact + - name: "EXEC: {Upload scenario report}" + if: always() && steps.start_cluster.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: scenario-report + path: ~/.foc-devnet/state/latest/scenario_*.md + if-no-files-found: ignore + # Clean shutdown - name: "EXEC: {Stop cluster}, independent" run: ./foc-devnet stop diff --git a/scenario/lib.sh b/scenario/lib.sh new file mode 100755 index 0000000..3bba87f --- /dev/null +++ b/scenario/lib.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# lib.sh — Shared helpers for scenario tests. +# +# Sourced (not executed) by each test_*.sh script. +# Provides: assertions, devnet-info access, and result tracking. +# +# ── Writing a new scenario ─────────────────────────────────── +# +# 1. Create scenario/test_.sh with this skeleton: +# +# #!/usr/bin/env bash +# set -euo pipefail +# SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +# source "${SCENARIO_DIR}/lib.sh" +# scenario_start "" +# +# # ... your checks using assert_*, jq_devnet, etc. ... +# +# scenario_end +# +# 2. Add "test_" to the SCENARIOS array in order.sh. +# 3. chmod +x scenario/test_.sh +# 4. Run: bash scenario/test_.sh +# +# ── Available helpers ──────────────────────────────────────── +# jq_devnet — query devnet-info.json +# assert_eq — equality check +# assert_gt — integer greater-than +# assert_not_empty — value is non-empty +# assert_ok — command exits 0 +# info / ok / fail — logging +# ───────────────────────────────────────────────────────────── +# shellcheck disable=SC2034 # Variables here are used by scripts that source this file +set -euo pipefail + +# ── Paths ──────────────────────────────────────────────────── +DEVNET_INFO="${DEVNET_INFO:-$HOME/.foc-devnet/state/latest/devnet-info.json}" +SCENARIO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" + +# Per-scenario counters (reset by scenario_start) +_PASS=0 +_FAIL=0 +_SCENARIO_NAME="" + +# ── devnet-info helpers ────────────────────────────────────── + +# Shorthand: jq_devnet '.info.users[0].evm_addr' +jq_devnet() { jq -r "$1" "$DEVNET_INFO"; } + +# ── Logging ────────────────────────────────────────────────── +_log() { printf "[%s] %s\n" "$1" "$2"; } +info() { _log "[INFO]" "$*"; } +ok() { + _log "[ OK ]" "$*" + ((_PASS++)) || true +} +fail() { + _log "[FAIL]" "$*" + ((_FAIL++)) || true +} + +# ── Assertions ─────────────────────────────────────────────── + +# assert_eq +assert_eq() { + if [[ "$1" == "$2" ]]; then ok "$3"; else fail "$3 (got '$1', want '$2')"; fi +} + +# assert_not_empty +assert_not_empty() { + if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi +} + +# assert_gt +# Both arguments are treated as integers (wei-scale is fine). +assert_gt() { + if python3 -c "import sys; sys.exit(0 if int('$1') > int('$2') else 1)" 2>/dev/null; then + ok "$3" + else + fail "$3 (got '$1', want > '$2')" + fi +} + +# assert_ok +# Runs the command; passes if exit-code == 0. +assert_ok() { + local msg="${*: -1}" + local cmd=("${@:1:$#-1}") + if "${cmd[@]}" >/dev/null 2>&1; then ok "$msg"; else fail "$msg"; fi +} + +# ── Scenario lifecycle ─────────────────────────────────────── + +scenario_start() { + _SCENARIO_NAME="$1" + _PASS=0 + _FAIL=0 + info "━━━ START: ${_SCENARIO_NAME} ━━━" +} + +scenario_end() { + local total=$((_PASS + _FAIL)) + local status="PASS" + [[ $_FAIL -gt 0 ]] && status="FAIL" + info "━━━ END: ${_SCENARIO_NAME} ${_PASS}/${total} passed [${status}] ━━━" + # Write machine-readable result line for the runner + mkdir -p "$REPORT_DIR" + echo "${status}|${_SCENARIO_NAME}|${_PASS}|${_FAIL}" >>"${REPORT_DIR}/results.csv" + [[ $_FAIL -eq 0 ]] +} diff --git a/scenario/order.sh b/scenario/order.sh new file mode 100755 index 0000000..2c10644 --- /dev/null +++ b/scenario/order.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# order.sh — Declares the scenario execution order. +# +# Each entry is the basename of a script under scenario/. +# Scenarios share the same running devnet and execute serially +# in the order listed here. +# ───────────────────────────────────────────────────────────── + +# shellcheck disable=SC2034 # SCENARIOS is used by run.sh which sources this file +SCENARIOS=( + test_containers + test_basic_balances +) diff --git a/scenario/run.sh b/scenario/run.sh new file mode 100755 index 0000000..72db7ff --- /dev/null +++ b/scenario/run.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# run.sh — Scenario test runner. +# +# Executes every scenario listed in order.sh against the +# currently-running devnet, collects results, prints a report, +# and (when REPORTING=true) files a GitHub issue. +# +# ── Running locally ────────────────────────────────────────── +# +# Prerequisites: +# - A running foc-devnet cluster (./foc-devnet start) +# - jq, python3, docker on PATH +# - (optional) Foundry — installed automatically by +# test_basic_balances if missing +# +# Quick start: +# ./foc-devnet start # bring up the devnet +# bash scenario/run.sh # run all scenarios +# cat ~/.foc-devnet/state/latest/scenario_*.md # read the report +# +# Run a single scenario: +# bash scenario/test_containers.sh +# +# Override devnet-info path (e.g. an older run): +# DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ +# bash scenario/run.sh +# +# File a GitHub issue on failure (needs `gh` CLI + auth): +# REPORTING=true bash scenario/run.sh +# +# Always file an issue, even on success: +# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenario/run.sh +# +# ── Environment variables ──────────────────────────────────── +# DEVNET_INFO — path to devnet-info.json (auto-detected) +# REPORTING — "true" to create a GitHub issue +# SKIP_REPORT_ON_PASS — "true" (default) skips the issue when +# all scenarios pass +# GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID +# — set automatically by GitHub Actions +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +REPORT_DIR="${REPORT_DIR:-$HOME/.foc-devnet/state/latest}" +REPORTING="${REPORTING:-false}" +SKIP_REPORT_ON_PASS="${SKIP_REPORT_ON_PASS:-true}" + +# ── Bootstrap ──────────────────────────────────────────────── +# Clean previous scenario artifacts (but not the whole state dir) +rm -f "${REPORT_DIR}"/scenario_*.md "${REPORT_DIR}/results.csv" +source "${SCENARIO_DIR}/order.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 +FAILED_NAMES=() +START_TS=$(date +%s) + +# ── Execute scenarios ──────────────────────────────────────── +for name in "${SCENARIOS[@]}"; do + script="${SCENARIO_DIR}/${name}.sh" + if [[ ! -f "$script" ]]; then + echo "[SKIP] ${name}.sh not found" + continue + fi + + ((TOTAL++)) || true + echo "" + # Each scenario runs in a subshell so a failure doesn't kill the runner + if bash "$script"; then + ((PASSED++)) || true + else + ((FAILED++)) || true + FAILED_NAMES+=("$name") + fi +done + +ELAPSED=$(($(date +%s) - START_TS)) + +# ── Build report ───────────────────────────────────────────── +REPORT="${REPORT_DIR}/scenario_$(date -u +%Y%m%d_%H%M%S).md" +{ + echo "# Scenario Test Report" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Total | ${TOTAL} |" + echo "| Passed | ${PASSED} |" + echo "| Failed | ${FAILED} |" + echo "| Duration | ${ELAPSED}s |" + echo "" + + # Per-scenario detail from the CSV each scenario appended + if [[ -f "${REPORT_DIR}/results.csv" ]]; then + echo "## Details" + echo "" + echo "| Status | Scenario | Passed | Failed |" + echo "|--------|----------|--------|--------|" + while IFS='|' read -r st sc pa fa; do + icon="✅" + [[ "$st" == "FAIL" ]] && icon="❌" + echo "| ${icon} ${st} | ${sc} | ${pa} | ${fa} |" + done <"${REPORT_DIR}/results.csv" + fi + + if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then + echo "" + echo "## Failed scenarios" + echo "" + for n in "${FAILED_NAMES[@]}"; do echo "- \`${n}\`"; done + fi +} >"$REPORT" + +# Print to stdout as well +cat "$REPORT" + +# ── GitHub issue (only when REPORTING=true) ────────────────── +SHOULD_REPORT=false +if [[ "$REPORTING" == "true" ]]; then + if [[ $FAILED -gt 0 ]]; then + SHOULD_REPORT=true + elif [[ "$SKIP_REPORT_ON_PASS" != "true" ]]; then + SHOULD_REPORT=true + fi +fi + +if [[ "$SHOULD_REPORT" == "true" ]]; then + RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-unknown}/actions/runs/${GITHUB_RUN_ID:-0}" + STATUS_EMOJI="✅" + [[ $FAILED -gt 0 ]] && STATUS_EMOJI="❌" + ISSUE_TITLE="${STATUS_EMOJI} Scenario report: ${PASSED}/${TOTAL} passed ($(date -u +%Y-%m-%d))" + ISSUE_BODY="$(cat "$REPORT") + +--- +[View workflow run](${RUN_URL})" + + LABELS="scenario-report" + [[ $FAILED -gt 0 ]] && LABELS="scenario-report,bug" + gh issue create \ + --title "$ISSUE_TITLE" \ + --body "$ISSUE_BODY" \ + --label "$LABELS" \ + || echo "[WARN] Could not create GitHub issue (gh CLI missing or auth failed)" +fi + +# ── Exit code reflects overall result ──────────────────────── +[[ $FAILED -eq 0 ]] diff --git a/scenario/test_basic_balances.sh b/scenario/test_basic_balances.sh new file mode 100755 index 0000000..a1cbf3b --- /dev/null +++ b/scenario/test_basic_balances.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# test_basic_balances.sh +# +# Installs Foundry (cast), then verifies every user account on +# the devnet has a positive tFIL balance and a positive USDFC +# token balance. +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "basic_balances" + +# ── Ensure Foundry is available ────────────────────────────── +if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" +fi +assert_ok command -v cast "cast is installed" + +# ── Read devnet info ───────────────────────────────────────── +RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') +USDFC_ADDR=$(jq_devnet '.info.contracts.mockusdfc_addr') +USER_COUNT=$(jq_devnet '.info.users | length') +assert_gt "$USER_COUNT" 0 "at least one user exists" + +# ── Check each user ────────────────────────────────────────── +for i in $(seq 0 $((USER_COUNT - 1))); do + NAME=$(jq_devnet ".info.users[$i].name") + ADDR=$(jq_devnet ".info.users[$i].evm_addr") + + # Native FIL balance (returned in wei) + FIL_WEI=$(cast balance "$ADDR" --rpc-url "$RPC_URL") + assert_gt "$FIL_WEI" 0 "${NAME} FIL balance > 0" + + # MockUSDFC ERC-20 balance + USDFC_WEI=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + assert_gt "$USDFC_WEI" 0 "${NAME} USDFC balance > 0" +done + +scenario_end diff --git a/scenario/test_containers.sh b/scenario/test_containers.sh new file mode 100755 index 0000000..7e18fc6 --- /dev/null +++ b/scenario/test_containers.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# test_containers.sh +# +# Verifies that all foc-* containers reported in devnet-info.json +# are actually running, healthy, and that no zombie foc-* +# containers exist outside the current run. +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "containers" + +# ── Collect expected container names from devnet-info ──────── +EXPECTED=() +EXPECTED+=("$(jq_devnet '.info.lotus.container_name')") +EXPECTED+=("$(jq_devnet '.info.lotus_miner.container_name')") + +# Each Curio SP also has a container +SP_COUNT=$(jq_devnet '.info.pdp_sps | length') +for i in $(seq 0 $((SP_COUNT - 1))); do + EXPECTED+=("$(jq_devnet ".info.pdp_sps[$i].container_name")") +done + +# ── Verify each expected container is running ──────────────── +for cname in "${EXPECTED[@]}"; do + STATUS=$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo "missing") + assert_eq "$STATUS" "running" "container ${cname} is running" +done + +# ── Check no unexpected foc-c-* containers are running ─────── +# All foc-c-* containers should belong to the expected set +RUNNING=$(docker ps --filter "name=foc-c-" --format '{{.Names}}') +for cname in $RUNNING; do + KNOWN=false + for exp in "${EXPECTED[@]}"; do + [[ "$cname" == "$exp" ]] && KNOWN=true && break + done + # Portainer is allowed but not in devnet-info + [[ "$cname" == *"-portainer"* ]] && KNOWN=true + assert_eq "$KNOWN" "true" "container ${cname} is expected" +done + +scenario_end From f0fdab53971319cb1128774233a2fb43c79d7425 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 14:55:38 +0000 Subject: [PATCH 2/9] feat: githooks, ADVANCED_README --- .githooks/install.sh | 7 ++ .githooks/pre-commit | 107 ++++++++++++++++++ .github/workflows/ci.yml | 10 +- README_ADVANCED.md | 55 +++++++++ {scenario => scenarios}/lib.sh | 6 +- {scenario => scenarios}/order.sh | 2 +- {scenario => scenarios}/run.sh | 10 +- .../test_basic_balances.sh | 0 {scenario => scenarios}/test_containers.sh | 0 9 files changed, 183 insertions(+), 14 deletions(-) create mode 100755 .githooks/install.sh create mode 100755 .githooks/pre-commit rename {scenario => scenarios}/lib.sh (96%) rename {scenario => scenarios}/order.sh (91%) rename {scenario => scenarios}/run.sh (95%) rename {scenario => scenarios}/test_basic_balances.sh (100%) rename {scenario => scenarios}/test_containers.sh (100%) diff --git a/.githooks/install.sh b/.githooks/install.sh new file mode 100755 index 0000000..f516fca --- /dev/null +++ b/.githooks/install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Installs the repo's git hooks by pointing core.hooksPath at .githooks/ +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +git -C "$REPO_ROOT" config core.hooksPath .githooks +chmod +x "$REPO_ROOT"/.githooks/* +echo "✓ Git hooks installed (.githooks/)" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..593706f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# pre-commit hook for foc-devnet +# +# Checks staged files before each commit: +# - Rust: cargo fmt, cargo clippy +# - Shell: shfmt, shellcheck, executable bit +# +# Install: bash .githooks/install.sh +# Skip once: git commit --no-verify +# +# Auto-fix mode (formats code and re-stages): +# FIX=1 git commit +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +FIX="${FIX:-0}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +FAIL=0 + +pass() { printf "${GREEN}✓${NC} %s\n" "$1"; } +fail() { printf "${RED}✗${NC} %s\n" "$1"; FAIL=1; } +skip() { printf "${YELLOW}⊘${NC} %s (skipped — tool not found)\n" "$1"; } +fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed & re-staged)\n" "$1"; } + +# Collect staged files +STAGED=$(git diff --cached --name-only --diff-filter=ACM) + +# ── Rust checks ────────────────────────────────────────────── +HAS_RS=$(echo "$STAGED" | grep -c '\.rs$' || true) +if [[ $HAS_RS -gt 0 ]]; then + if command -v cargo &>/dev/null; then + if cargo fmt --all -- --check &>/dev/null; then + pass "cargo fmt" + elif [[ "$FIX" == "1" ]]; then + cargo fmt --all + git diff --name-only -- '*.rs' | xargs -r git add + fixed "cargo fmt" + else + fail "cargo fmt — run 'cargo fmt' or FIX=1 git commit" + fi + + if cargo clippy --all-targets --all-features -- -D warnings &>/dev/null; then + pass "cargo clippy" + else + fail "cargo clippy — fix warnings before committing" + fi + else + skip "cargo (Rust checks)" + fi +fi + +# ── Shell checks ───────────────────────────────────────────── +STAGED_SH=$(echo "$STAGED" | grep '\.sh$' || true) +if [[ -n "$STAGED_SH" ]]; then + # Executable bit + for f in $STAGED_SH; do + if [[ -f "$f" && ! -x "$f" ]]; then + if [[ "$FIX" == "1" ]]; then + chmod +x "$f" + git add "$f" + fixed "${f} +x" + else + fail "${f} is not executable — run 'chmod +x ${f}'" + fi + fi + done + + # shfmt + if command -v shfmt &>/dev/null; then + if shfmt -d -i 2 -bn $STAGED_SH &>/dev/null; then + pass "shfmt" + elif [[ "$FIX" == "1" ]]; then + shfmt -w -i 2 -bn $STAGED_SH + echo "$STAGED_SH" | xargs -r git add + fixed "shfmt" + else + fail "shfmt — run 'shfmt -w -i 2 -bn ' or FIX=1 git commit" + fi + else + skip "shfmt" + fi + + # shellcheck (no auto-fix available) + if command -v shellcheck &>/dev/null; then + if shellcheck -S warning $STAGED_SH &>/dev/null; then + pass "shellcheck" + else + fail "shellcheck — run 'shellcheck -S warning ' to see issues" + fi + else + skip "shellcheck" + fi +fi + +# ── Result ─────────────────────────────────────────────────── +if [[ $FAIL -ne 0 ]]; then + echo "" + printf "${RED}Pre-commit checks failed.${NC} Fix issues above or skip with: git commit --no-verify\n" + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63a5fbc..0afebcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,21 +49,21 @@ jobs: - name: Install shfmt run: sudo snap install shfmt - # Ensure all .sh files under scenario/ are executable + # Ensure all .sh files under scenarios/ are executable - name: Check executable permissions run: | - bad=$(find scenario -name '*.sh' ! -perm -u+x) + bad=$(find scenarios -name '*.sh' ! -perm -u+x) if [ -n "$bad" ]; then echo "Missing +x on:" >&2; echo "$bad" >&2; exit 1 fi # Lint with shellcheck - name: shellcheck - run: find scenario -name '*.sh' -exec shellcheck -S warning {} + + run: find scenarios -name '*.sh' -exec shellcheck -S warning {} + # Verify consistent formatting (indent=2, binary ops start of line) - name: shfmt - run: shfmt -d -i 2 -bn scenario/ + run: shfmt -d -i 2 -bn scenarios/ foc-start-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] @@ -397,7 +397,7 @@ jobs: # By default, don't file an issue if everything passes SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass != false }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scenario/run.sh + run: bash scenarios/run.sh # Upload scenario report as artifact - name: "EXEC: {Upload scenario report}" diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 5837540..1489c90 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1296,3 +1296,58 @@ docker run --rm --network host \ --broadcast ``` +## Scenario Tests + +Scenario tests are lightweight shell scripts that validate scenarios on the devnet after startup. They share a single running devnet and execute serially in a defined order. + +### Running scenarios + +```bash +# Run all scenarios against a running devnet +bash scenarios/run.sh + +# Run a single scenario +bash scenarios/test_basic_balances.sh + +# Point at a specific devnet run +DEVNET_INFO=~/.foc-devnet/state//devnet-info.json bash scenarios/run.sh +``` + +Reports are written to `~/.foc-devnet/state/latest/scenario_.md`. + +### Writing a new scenario + +1. Create `scenarios/test_.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCENARIO_DIR}/lib.sh" +scenario_start "" + +# Use helpers: jq_devnet, assert_eq, assert_gt, assert_not_empty, assert_ok +RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') +BALANCE=$(cast balance 0x... --rpc-url "$RPC_URL") +assert_gt "$BALANCE" 0 "account has funds" + +scenario_end +``` + +2. Add `test_` to the `SCENARIOS` array in `scenarios/order.sh`. +3. `chmod +x scenarios/test_.sh` + +### Available assertion helpers (from `lib.sh`) + +| Helper | Usage | Description | +|--------|-------|-------------| +| `assert_eq` | `assert_eq "$a" "$b" "msg"` | Equality check | +| `assert_gt` | `assert_gt "$a" "$b" "msg"` | Integer greater-than (handles wei-scale) | +| `assert_not_empty` | `assert_not_empty "$v" "msg"` | Value is non-empty | +| `assert_ok` | `assert_ok cmd arg... "msg"` | Command exits 0 | +| `jq_devnet` | `jq_devnet '.info.lotus.host_rpc_url'` | Query devnet-info.json | + +### CI integration + +Scenarios run automatically in CI after the devnet starts. On nightly runs (or manual dispatch with `reporting` enabled), failures automatically create a GitHub issue with a full report. + diff --git a/scenario/lib.sh b/scenarios/lib.sh similarity index 96% rename from scenario/lib.sh rename to scenarios/lib.sh index 3bba87f..3349414 100755 --- a/scenario/lib.sh +++ b/scenarios/lib.sh @@ -7,7 +7,7 @@ # # ── Writing a new scenario ─────────────────────────────────── # -# 1. Create scenario/test_.sh with this skeleton: +# 1. Create scenarios/test_.sh with this skeleton: # # #!/usr/bin/env bash # set -euo pipefail @@ -20,8 +20,8 @@ # scenario_end # # 2. Add "test_" to the SCENARIOS array in order.sh. -# 3. chmod +x scenario/test_.sh -# 4. Run: bash scenario/test_.sh +# 3. chmod +x scenarios/test_.sh +# 4. Run: bash scenarios/test_.sh # # ── Available helpers ──────────────────────────────────────── # jq_devnet — query devnet-info.json diff --git a/scenario/order.sh b/scenarios/order.sh similarity index 91% rename from scenario/order.sh rename to scenarios/order.sh index 2c10644..2a99c89 100755 --- a/scenario/order.sh +++ b/scenarios/order.sh @@ -2,7 +2,7 @@ # ───────────────────────────────────────────────────────────── # order.sh — Declares the scenario execution order. # -# Each entry is the basename of a script under scenario/. +# Each entry is the basename of a script under scenarios/. # Scenarios share the same running devnet and execute serially # in the order listed here. # ───────────────────────────────────────────────────────────── diff --git a/scenario/run.sh b/scenarios/run.sh similarity index 95% rename from scenario/run.sh rename to scenarios/run.sh index 72db7ff..d278b36 100755 --- a/scenario/run.sh +++ b/scenarios/run.sh @@ -16,21 +16,21 @@ # # Quick start: # ./foc-devnet start # bring up the devnet -# bash scenario/run.sh # run all scenarios +# bash scenarios/run.sh # run all scenarios # cat ~/.foc-devnet/state/latest/scenario_*.md # read the report # # Run a single scenario: -# bash scenario/test_containers.sh +# bash scenarios/test_containers.sh # # Override devnet-info path (e.g. an older run): # DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ -# bash scenario/run.sh +# bash scenarios/run.sh # # File a GitHub issue on failure (needs `gh` CLI + auth): -# REPORTING=true bash scenario/run.sh +# REPORTING=true bash scenarios/run.sh # # Always file an issue, even on success: -# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenario/run.sh +# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenarios/run.sh # # ── Environment variables ──────────────────────────────────── # DEVNET_INFO — path to devnet-info.json (auto-detected) diff --git a/scenario/test_basic_balances.sh b/scenarios/test_basic_balances.sh similarity index 100% rename from scenario/test_basic_balances.sh rename to scenarios/test_basic_balances.sh diff --git a/scenario/test_containers.sh b/scenarios/test_containers.sh similarity index 100% rename from scenario/test_containers.sh rename to scenarios/test_containers.sh From 803aeb993c9f1e7f21b18941517152f6cf45fdd3 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Mon, 2 Mar 2026 15:00:48 +0000 Subject: [PATCH 3/9] rename: foc-start-test to foc-devnet-test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0afebcd..d06049c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: - name: shfmt run: shfmt -d -i 2 -bn scenarios/ - foc-start-test: + foc-devnet-test: runs-on: ["self-hosted", "linux", "x64", "16xlarge+gpu"] timeout-minutes: 100 permissions: From 930d7ebcc1b08ef9fa8df5ba265a1b7274a7a01a Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 06:54:23 +0000 Subject: [PATCH 4/9] add: fixes --- .githooks/pre-commit | 5 +++-- scenarios/lib.sh | 13 +++++++++---- scenarios/run.sh | 2 ++ scenarios/test_basic_balances.sh | 3 ++- scenarios/test_containers.sh | 19 +++++++++++++------ 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 593706f..8bd71ed 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -33,14 +33,15 @@ fixed() { printf "${BLUE}⟳${NC} %s (auto-fixed & re-staged)\n" "$1"; } STAGED=$(git diff --cached --name-only --diff-filter=ACM) # ── Rust checks ────────────────────────────────────────────── -HAS_RS=$(echo "$STAGED" | grep -c '\.rs$' || true) +STAGED_RS=$(echo "$STAGED" | grep '\.rs$' || true) +HAS_RS=$(echo "$STAGED_RS" | grep -c '\.rs$' || true) if [[ $HAS_RS -gt 0 ]]; then if command -v cargo &>/dev/null; then if cargo fmt --all -- --check &>/dev/null; then pass "cargo fmt" elif [[ "$FIX" == "1" ]]; then cargo fmt --all - git diff --name-only -- '*.rs' | xargs -r git add + echo "$STAGED_RS" | xargs -r git add fixed "cargo fmt" else fail "cargo fmt — run 'cargo fmt' or FIX=1 git commit" diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 3349414..6d405fd 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -47,17 +47,22 @@ _SCENARIO_NAME="" # ── devnet-info helpers ────────────────────────────────────── # Shorthand: jq_devnet '.info.users[0].evm_addr' -jq_devnet() { jq -r "$1" "$DEVNET_INFO"; } +jq_devnet() { + if ! jq -r "$1" "$DEVNET_INFO"; then + fail "jq_devnet: jq failed for filter '$1' on '$DEVNET_INFO'" + return 1 + fi +} # ── Logging ────────────────────────────────────────────────── _log() { printf "[%s] %s\n" "$1" "$2"; } -info() { _log "[INFO]" "$*"; } +info() { _log "INFO" "$*"; } ok() { - _log "[ OK ]" "$*" + _log " OK " "$*" ((_PASS++)) || true } fail() { - _log "[FAIL]" "$*" + _log "FAIL" "$*" ((_FAIL++)) || true } diff --git a/scenarios/run.sh b/scenarios/run.sh index d278b36..3509109 100755 --- a/scenarios/run.sh +++ b/scenarios/run.sh @@ -48,6 +48,8 @@ REPORTING="${REPORTING:-false}" SKIP_REPORT_ON_PASS="${SKIP_REPORT_ON_PASS:-true}" # ── Bootstrap ──────────────────────────────────────────────── +# Ensure report directory exists before cleaning/writing artifacts +mkdir -p "${REPORT_DIR}" # Clean previous scenario artifacts (but not the whole state dir) rm -f "${REPORT_DIR}"/scenario_*.md "${REPORT_DIR}/results.csv" source "${SCENARIO_DIR}/order.sh" diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index a1cbf3b..221e4f2 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -10,13 +10,14 @@ set -euo pipefail SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCENARIO_DIR}/lib.sh" -scenario_start "basic_balances" +scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── if ! command -v cast &>/dev/null; then info "Installing Foundry …" curl -sSL https://foundry.paradigm.xyz | bash export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" fi assert_ok command -v cast "cast is installed" diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh index 7e18fc6..72a55d9 100755 --- a/scenarios/test_containers.sh +++ b/scenarios/test_containers.sh @@ -3,14 +3,14 @@ # test_containers.sh # # Verifies that all foc-* containers reported in devnet-info.json -# are actually running, healthy, and that no zombie foc-* -# containers exist outside the current run. +# are actually running and that no unexpected foc-* containers +# exist outside the current run. # ───────────────────────────────────────────────────────────── set -euo pipefail SCENARIO_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCENARIO_DIR}/lib.sh" -scenario_start "containers" +scenario_start "test_containers" # ── Collect expected container names from devnet-info ──────── EXPECTED=() @@ -29,9 +29,16 @@ for cname in "${EXPECTED[@]}"; do assert_eq "$STATUS" "running" "container ${cname} is running" done -# ── Check no unexpected foc-c-* containers are running ─────── -# All foc-c-* containers should belong to the expected set -RUNNING=$(docker ps --filter "name=foc-c-" --format '{{.Names}}') +# ── Check no unexpected foc-* containers are running ───────── +# All foc-* containers for this devnet run should belong to the expected set. +# Prefer the run-scoped prefix from devnet-info when available, fall back to foc-. +RUN_ID="$(jq_devnet '.info.run_id // ""')" +if [[ -n "$RUN_ID" ]]; then + NAME_FILTER="foc-${RUN_ID}-" +else + NAME_FILTER="foc-" +fi +RUNNING=$(docker ps --filter "name=${NAME_FILTER}" --format '{{.Names}}') for cname in $RUNNING; do KNOWN=false for exp in "${EXPECTED[@]}"; do From d077ef5cafc5abe913d1eb99c9748c60dd296bd5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 07:53:00 +0000 Subject: [PATCH 5/9] add: support for latestCommit and latestTag --- .githooks/pre-commit | 2 +- .github/workflows/ci.yml | 18 +- Cargo.lock | 7 + Cargo.toml | 1 + src/commands/build/repository.rs | 9 + src/commands/init/config.rs | 14 +- src/commands/init/latest_resolver.rs | 347 ++++++++++++++++++++++++++ src/commands/init/mod.rs | 1 + src/commands/init/repositories.rs | 7 + src/commands/status/git/formatters.rs | 14 ++ src/commands/status/git/repo_paths.rs | 8 +- src/config.rs | 62 ++++- src/main_app/version.rs | 6 + 13 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 src/commands/init/latest_resolver.rs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8bd71ed..6479605 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -88,7 +88,7 @@ if [[ -n "$STAGED_SH" ]]; then skip "shfmt" fi - # shellcheck (no auto-fix available) + # SC — no auto-fix available if command -v shellcheck &>/dev/null; then if shellcheck -S warning $STAGED_SH &>/dev/null; then pass "shellcheck" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d06049c..0fbb0f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,14 +193,28 @@ jobs: if: steps.cache-docker-images.outputs.cache-hit == 'true' run: | rm -rf ~/.foc-devnet - ./foc-devnet init --no-docker-build + if [[ "${{ github.event_name }}" == "schedule" ]]; then + ./foc-devnet init --no-docker-build \ + --curio latestCommit \ + --filecoin-services latestCommit \ + --lotus latestTag + else + ./foc-devnet init --no-docker-build + fi # If Docker images are not cached, do full init (downloads YugabyteDB and builds all images) - name: "EXEC: {Initialize without cache}, independent" if: steps.cache-docker-images.outputs.cache-hit != 'true' run: | rm -rf ~/.foc-devnet - ./foc-devnet init + if [[ "${{ github.event_name }}" == "schedule" ]]; then + ./foc-devnet init \ + --curio latestCommit \ + --filecoin-services latestCommit \ + --lotus latestTag + else + ./foc-devnet init + fi # CACHE-DOCKER: Build Docker images if not cached - name: "EXEC: {Build Docker images}, DEP: {C-docker-images-cache}" diff --git a/Cargo.lock b/Cargo.lock index f195c51..1665eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.11.27", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -2351,6 +2352,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index d3b62ba..ef8976a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ base32 = "0.4" crc32fast = "1.3" bip32 = "0.5" rand = "0.8" +semver = "1.0" bls-signatures = "0.15" names = { version = "0.14", default-features = false } shellexpand = "3" diff --git a/src/commands/build/repository.rs b/src/commands/build/repository.rs index 7520f03..b2d0b83 100644 --- a/src/commands/build/repository.rs +++ b/src/commands/build/repository.rs @@ -50,6 +50,15 @@ pub fn prepare_repository( prepare_git_repo(&repo_path, url)?; checkout_branch(&repo_path, branch)?; } + // LatestCommit / LatestTag are resolved to GitCommit / GitTag at init time + // and never appear in a saved config.toml, so this is a programming error. + Location::LatestCommit { .. } | Location::LatestTag { .. } => { + return Err( + "Dynamic location (latestCommit/latestTag) was not resolved before build. \ + Run 'foc-devnet init' first." + .into(), + ); + } } info!("Repository prepared successfully"); diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index 82966f3..b14b1ed 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -1,11 +1,15 @@ //! Configuration generation utilities for foc-devnet initialization. //! //! This module handles the generation of default configuration files -//! and application of location overrides. +//! and application of location overrides. Dynamic location variants +//! (`LatestCommit`, `LatestTag`) are resolved to concrete values at init +//! time via [`super::latest_resolver`], ensuring the stored config always +//! records the exact commit or tag that was used. use std::fs; use tracing::{info, warn}; +use super::latest_resolver::resolve_location; use crate::config::{Config, Location}; use crate::paths::foc_devnet_config; @@ -67,6 +71,12 @@ pub fn generate_default_config( "https://github.com/FilOzone/filecoin-services.git", )?; + // Resolve any dynamic variants (LatestCommit / LatestTag) by querying the remote. + // The resolved concrete SHA or tag is stored in config.toml for reproducibility. + config.lotus = resolve_location(config.lotus)?; + config.curio = resolve_location(config.curio)?; + config.filecoin_services = resolve_location(config.filecoin_services)?; + // Override yugabyte URL if provided if let Some(url) = yugabyte_url { config.yugabyte_download_url = url; @@ -103,6 +113,8 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), + Location::LatestCommit { ref url } => url.clone(), + Location::LatestTag { ref url } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; *location = Location::parse_with_default(&loc_str, &url) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs new file mode 100644 index 0000000..dd2fa45 --- /dev/null +++ b/src/commands/init/latest_resolver.rs @@ -0,0 +1,347 @@ +//! Resolver for dynamic location variants (`LatestCommit`, `LatestTag`). +//! +//! Queries remote Git repositories to resolve dynamic location variants to +//! concrete `GitCommit` / `GitTag` values at init time. The resolved SHA or +//! tag is then written to `config.toml` so that builds are always reproducible +//! and the exact version is recorded in the run state. +//! +//! `LatestCommit` uses `git ls-remote` to resolve to the tip of `main` +//! (or `master` if `main` does not exist — no local clone needed). +//! +//! `LatestTag` performs a blobless bare fetch of the default branch (`main` +//! or `master`) and all +//! tags into a temporary directory, then runs `git tag --merged main` to +//! enumerate only tags whose commits are reachable from `main`. This avoids +//! picking tags that were created on feature branches or release branches that +//! diverged from `main`. +//! +//! # Example +//! +//! ```text +//! foc-devnet init --curio latestCommit --lotus latestTag +//! // Queries remote → resolves to GitCommit { commit: "abc123..." } +//! // GitTag { tag: "v1.34.5" } +//! // Stores concrete values in config.toml +//! ``` + +use crate::config::Location; +use semver::Version; +use std::process::Command; +use tracing::info; + +/// Temporary bare repo used for tag-reachability queries. +struct TempBareRepo(std::path::PathBuf); + +impl TempBareRepo { + /// Initialise an empty bare repository in a system temp directory. + fn create() -> Result> { + let dir = std::env::temp_dir().join(format!( + "foc-devnet-tag-probe-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let status = Command::new("git") + .args(["init", "--bare", dir.to_str().unwrap()]) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + if !status.success() { + return Err("git init --bare failed".into()); + } + Ok(Self(dir)) + } + + /// Return the path of this bare repo. + fn path(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempBareRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Resolve a `Location` to a concrete variant by querying the remote if needed. +/// +/// `LatestCommit` and `LatestTag` are resolved against the remote repository. +/// All other variants are returned unchanged. +pub fn resolve_location(location: Location) -> Result> { + match location { + Location::LatestCommit { url } => { + let commit = fetch_latest_commit(&url)?; + info!("Resolved latestCommit for {} → {}", url, commit); + Ok(Location::GitCommit { url, commit }) + } + Location::LatestTag { url } => { + let tag = fetch_latest_tag(&url)?; + info!("Resolved latestTag for {} → {}", url, tag); + Ok(Location::GitTag { url, tag }) + } + other => Ok(other), + } +} + +/// Fetch the SHA of the tip of the default branch (`main` or `master`) on a remote. +/// +/// Tries `main` first; if it doesn't exist on the remote, falls back to `master`. +/// Fails if neither branch is found. +fn fetch_latest_commit(url: &str) -> Result> { + let branch = resolve_default_branch(url)?; + info!("Fetching latest commit on {} from {}", branch, url); + + let output = Command::new("git") + .args(["ls-remote", url, &format!("refs/heads/{}", branch)]) + .env("GIT_TERMINAL_PROMPT", "0") + .output()?; + + if !output.status.success() { + return Err(format!( + "git ls-remote failed for {}: {}", + url, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + + parse_ls_remote_commit(&String::from_utf8_lossy(&output.stdout), url) +} + +/// Parse the commit SHA from `git ls-remote` stdout. +/// +/// The output would be of the form: +/// ``` +/// 7741226198083e943a64d917e88a0a77d17aa30e refs/heads/master +/// ``` +fn parse_ls_remote_commit(stdout: &str, url: &str) -> Result> { + stdout + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .map(str::to_string) + .ok_or_else(|| format!("No commit found in ls-remote output for {}", url).into()) +} + +/// Resolve the default branch name for a remote repository. +/// +/// Queries the remote for both `main` and `master` in a single `git ls-remote` +/// call. Returns `"main"` if it exists, `"master"` if only that exists, or an +/// error if neither is present. +fn resolve_default_branch(url: &str) -> Result> { + let output = Command::new("git") + .args([ + "ls-remote", + "--heads", + url, + "refs/heads/main", + "refs/heads/master", + ]) + .env("GIT_TERMINAL_PROMPT", "0") + .output()?; + + if !output.status.success() { + return Err(format!( + "git ls-remote --heads failed for {}: {}", + url, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + + if has_main { + Ok("main".to_string()) + } else if has_master { + info!( + "Remote {} has no 'main' branch, falling back to 'master'", + url + ); + Ok("master".to_string()) + } else { + Err(format!("Remote {} has neither 'main' nor 'master' branch", url).into()) + } +} + +/// Fetch the highest stable semver tag for a remote repo, considering only +/// tags whose commits are reachable from the default branch (`main` or `master`). +/// +/// Strategy: +/// 1. Resolve the default branch (`main`, falling back to `master`). +/// 2. Create a throwaway bare repo in a temp directory. +/// 3. Blobless-fetch the default branch and all tags from the remote (no file +/// content downloaded — only commit and tree objects). +/// 4. Run `git tag --merged ` to enumerate tags reachable from it. +/// 5. Filter for stable semver tags (no `-rc`, `-alpha`, etc.) and return +/// the highest by numeric segment comparison. +fn fetch_latest_tag(url: &str) -> Result> { + let branch = resolve_default_branch(url)?; + info!("Fetching latest stable tag on {} from {}", branch, url); + + let repo = TempBareRepo::create()?; + + fetch_default_branch_and_tags(repo.path(), url, &branch)?; + + let tags_output = Command::new("git") + .args(["tag", "--merged", &branch]) + .current_dir(repo.path()) + .output()?; + + if !tags_output.status.success() { + return Err(format!( + "git tag --merged {} failed: {}", + branch, + String::from_utf8_lossy(&tags_output.stderr).trim() + ) + .into()); + } + + parse_latest_tag(&String::from_utf8_lossy(&tags_output.stdout), url) +} + +/// Fetch the default branch and all tags from `url` into an existing bare repo. +/// +/// Uses `--filter=blob:none` so only commit and tree objects are transferred +/// (no file content), keeping the operation fast even for large repositories. +fn fetch_default_branch_and_tags( + repo_path: &std::path::Path, + url: &str, + branch: &str, +) -> Result<(), Box> { + let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch); + let status = Command::new("git") + .args(["fetch", "--tags", "--filter=blob:none", url, &refspec]) + .current_dir(repo_path) + .env("GIT_TERMINAL_PROMPT", "0") + .status()?; + + if !status.success() { + return Err(format!("git fetch failed for {}", url).into()); + } + Ok(()) +} + +/// Parse and return the highest stable semver tag from `git tag --merged` stdout. +/// +/// Each line is a plain tag name (e.g. `v1.2.3`). Tags that cannot be parsed +/// as a valid semver version, or that carry a pre-release identifier (e.g. +/// `-rc1`, `-alpha`, `-beta`), are silently skipped. The remaining tags are +/// sorted by the `semver::Version` `Ord` implementation and the highest is +/// returned. +fn parse_latest_tag(stdout: &str, url: &str) -> Result> { + let mut tags: Vec<(Version, &str)> = stdout + .lines() + .map(str::trim) + .filter_map(|tag| { + // Strip leading 'v' before parsing, semver crate requires bare `1.2.3` + let raw = tag.trim_start_matches('v'); + Version::parse(raw).ok().map(|v| (v, tag)) + }) + .filter(|(v, _)| v.pre.is_empty()) // exclude pre-release versions + .collect(); + + if tags.is_empty() { + return Err(format!( + "No stable semver tags reachable from default branch for {}", + url + ) + .into()); + } + + tags.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(tags.last().unwrap().1.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_semver_sort_picks_highest() { + let output = "v1.9.0\nv1.10.0\nv1.2.3\nv1.10.1\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.10.1"); + } + + #[test] + fn test_semver_sort_ignores_rc_suffix() { + let output = "v1.34.3\nv1.34.4\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.34.4"); + } + + #[test] + fn test_parse_ls_remote_commit() { + let output = "abc123def456\tHEAD\n"; + let commit = parse_ls_remote_commit(output, "https://example.com/repo").unwrap(); + assert_eq!(commit, "abc123def456"); + } + + /// Simulate the stdout of `git ls-remote --heads` for branch resolution. + fn ls_remote_heads(branches: &[&str]) -> String { + branches + .iter() + .map(|b| format!("deadbeef\trefs/heads/{}\n", b)) + .collect() + } + + #[test] + fn test_resolve_default_branch_prefers_main() { + let stdout = ls_remote_heads(&["main", "master"]); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + assert!(has_main); + let branch = if has_main { + "main" + } else if has_master { + "master" + } else { + "" + }; + assert_eq!(branch, "main"); + } + + #[test] + fn test_resolve_default_branch_falls_back_to_master() { + let stdout = ls_remote_heads(&["master", "develop"]); + let has_main = stdout.lines().any(|l| l.contains("refs/heads/main")); + let has_master = stdout.lines().any(|l| l.contains("refs/heads/master")); + assert!(!has_main); + assert!(has_master); + let branch = if has_main { + "main" + } else if has_master { + "master" + } else { + "" + }; + assert_eq!(branch, "master"); + } + + #[test] + fn test_parse_latest_tag() { + let output = "v1.0.0\nv1.2.0\nv1.1.0\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.2.0"); + } + + #[test] + fn test_parse_latest_tag_skips_rc() { + // v1.2.0-rc1 is excluded; v1.1.0 is the latest stable + let output = "v1.0.0\nv1.1.0\nv1.2.0-rc1\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.1.0"); + } + + #[test] + fn test_parse_latest_tag_skips_non_semver() { + // "latest" and bare "rc" strings should be silently ignored + let output = "latest\nv1.0.0\nrc\nv1.1.0\n"; + let tag = parse_latest_tag(output, "https://example.com/repo").unwrap(); + assert_eq!(tag, "v1.1.0"); + } +} diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 25b0932..e42338c 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -13,6 +13,7 @@ pub mod artifacts; pub mod config; pub mod directories; pub mod keys; +pub mod latest_resolver; pub mod path_setup; pub mod repositories; diff --git a/src/commands/init/repositories.rs b/src/commands/init/repositories.rs index a23fa39..fbdb7d5 100644 --- a/src/commands/init/repositories.rs +++ b/src/commands/init/repositories.rs @@ -99,6 +99,13 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box { clone_and_checkout(name, url, None, None, Some(branch)) } + // LatestCommit / LatestTag are resolved to concrete variants before this function + // is called, so reaching here indicates a programming error. + Location::LatestCommit { .. } | Location::LatestTag { .. } => Err( + "Dynamic location (latestCommit/latestTag) was not resolved before repository \ + download. This is an internal error." + .into(), + ), } } diff --git a/src/commands/status/git/formatters.rs b/src/commands/status/git/formatters.rs index 20a92e5..d042237 100644 --- a/src/commands/status/git/formatters.rs +++ b/src/commands/status/git/formatters.rs @@ -78,6 +78,9 @@ pub fn format_location_info( ) if expected_branch == actual_branch => true, (Location::GitBranch { .. }, GitInfo::Tag(_) | GitInfo::Commit(_)) => true, // Assume it's ready if we have some valid state (Location::GitBranch { .. }, _) => false, + + // LatestCommit / LatestTag are resolved at init time; treat as not ready if somehow present. + (Location::LatestCommit { .. } | Location::LatestTag { .. }, _) => false, }; let status = if is_ready { @@ -125,6 +128,17 @@ pub fn format_location_info( "Not found".to_string(), ), }, + // Resolved at init time; display as their underlying type if somehow still present. + Location::LatestCommit { .. } => ( + "Latest Commit".to_string(), + "(unresolved)".to_string(), + "".to_string(), + ), + Location::LatestTag { .. } => ( + "Latest Tag".to_string(), + "(unresolved)".to_string(), + "".to_string(), + ), }; (source_type, version, commit, status) diff --git a/src/commands/status/git/repo_paths.rs b/src/commands/status/git/repo_paths.rs index b6fd59e..2b4571c 100644 --- a/src/commands/status/git/repo_paths.rs +++ b/src/commands/status/git/repo_paths.rs @@ -34,8 +34,12 @@ pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::p // For local sources, check the specified directory std::path::PathBuf::from(dir) } - Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } => { - // For git sources, check if it exists in the foc-devnet code directory + Location::GitTag { .. } + | Location::GitCommit { .. } + | Location::GitBranch { .. } + | Location::LatestCommit { .. } + | Location::LatestTag { .. } => { + // For git sources (including unresolved dynamic variants), use the foc-devnet code directory foc_devnet_code().join(component) } } diff --git a/src/config.rs b/src/config.rs index 85b3e82..cccf0a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,26 +36,63 @@ pub enum Location { /// The `url` field is the Git repository URL, and `branch` is the specific /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, + + /// Resolve to the latest commit on the default branch of the repository at init time. + /// + /// The `url` field is the Git repository URL. When used with `foc-devnet init`, + /// this is immediately resolved to a concrete `GitCommit` by querying the remote, + /// so the stored config always records the exact SHA used. + /// + /// Example CLI usage: `--curio latestCommit` + LatestCommit { url: String }, + + /// Resolve to the latest tag of the repository at init time. + /// + /// The `url` field is the Git repository URL. When used with `foc-devnet init`, + /// this is immediately resolved to a concrete `GitTag` by querying the remote, + /// so the stored config always records the exact tag used. + /// + /// Example CLI usage: `--lotus latestTag` + LatestTag { url: String }, } impl Location { /// Parse a location string in the format "type:value" or "type:url:value" /// /// Supported formats: - /// - "gittag:tag" (uses default URL) - /// - "gitcommit:commit" (uses default URL) - /// - "gitbranch:branch" (uses default URL) - /// - "local:dir" - /// - "gittag:url:tag" - /// - "gitcommit:url:commit" - /// - "gitbranch:url:branch" + /// - `latestCommit` — resolves to latest HEAD commit (uses default URL) + /// - `latestTag` — resolves to latest semver tag (uses default URL) + /// - `latestCommit:url` — same, with explicit URL + /// - `latestTag:url` — same, with explicit URL + /// - `gittag:tag` — (uses default URL) + /// - `gitcommit:commit` — (uses default URL) + /// - `gitbranch:branch` — (uses default URL) + /// - `local:dir` + /// - `gittag:url:tag` + /// - `gitcommit:url:commit` + /// - `gitbranch:url:branch` /// /// Where url can contain colons (e.g., https://github.com/repo.git) pub fn parse_with_default(s: &str, default_url: &str) -> Result { + // Handle bare magic keywords (no colon) — use default URL + match s { + "latestCommit" => { + return Ok(Location::LatestCommit { + url: default_url.to_string(), + }) + } + "latestTag" => { + return Ok(Location::LatestTag { + url: default_url.to_string(), + }) + } + _ => {} + } + let parts: Vec<&str> = s.split(':').collect(); if parts.len() < 2 { return Err(format!( - "Invalid location format: {}. Expected 'type:value' or 'type:url:value'", + "Invalid location format: {}. Expected 'type:value', 'latestCommit', or 'latestTag'", s )); } @@ -64,6 +101,13 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { + // latestCommit:url and latestTag:url forms + "latestCommit" => Ok(Location::LatestCommit { + url: remaining.to_string(), + }), + "latestTag" => Ok(Location::LatestTag { + url: remaining.to_string(), + }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), }), @@ -107,7 +151,7 @@ impl Location { } } _ => Err(format!( - "Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch", + "Unknown location type: {}. Supported types: latestCommit, latestTag, local, gittag, gitcommit, gitbranch", location_type )), } diff --git a/src/main_app/version.rs b/src/main_app/version.rs index a0c6a57..a881012 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,5 +70,11 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } + Location::LatestCommit { url } => { + info!("{}: {}, latest commit (unresolved)", label, url); + } + Location::LatestTag { url } => { + info!("{}: {}, latest tag (unresolved)", label, url); + } } } From e6bedf028f294e5980d2335097ff3190b31a9c91 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:13:26 +0000 Subject: [PATCH 6/9] fix: scenarios --- scenarios/test_basic_balances.sh | 1 + scenarios/test_containers.sh | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index 221e4f2..7eef1eb 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -15,6 +15,7 @@ scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── if ! command -v cast &>/dev/null; then info "Installing Foundry …" + export SHELL=/bin/bash curl -sSL https://foundry.paradigm.xyz | bash export PATH="$HOME/.foundry/bin:$PATH" "$HOME/.foundry/bin/foundryup" diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh index 72a55d9..7be80f9 100755 --- a/scenarios/test_containers.sh +++ b/scenarios/test_containers.sh @@ -23,6 +23,12 @@ for i in $(seq 0 $((SP_COUNT - 1))); do EXPECTED+=("$(jq_devnet ".info.pdp_sps[$i].container_name")") done +# Each Curio SP also has a YugabyteDB container +RUN_ID=$(jq_devnet '.info.run_id') +for i in $(seq 1 $SP_COUNT); do + EXPECTED+=("foc-${RUN_ID}-yugabyte-${i}") +done + # ── Verify each expected container is running ──────────────── for cname in "${EXPECTED[@]}"; do STATUS=$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || echo "missing") From f7452f601371b99c3ba4c3724dc3e2471001a2f5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:35:22 +0000 Subject: [PATCH 7/9] fix: test_basic_balances --- scenarios/lib.sh | 11 +++++++++++ scenarios/test_basic_balances.sh | 12 +++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 6d405fd..5956b49 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -75,6 +75,17 @@ assert_eq() { # assert_not_empty assert_not_empty() { + # ── Foundry install (hoisted) ─────────────────────────────── + ensure_foundry() { + if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + export SHELL=/bin/bash + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" + fi + assert_ok command -v cast "cast is installed" + } if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi } diff --git a/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh index 7eef1eb..66d0f87 100755 --- a/scenarios/test_basic_balances.sh +++ b/scenarios/test_basic_balances.sh @@ -13,14 +13,7 @@ source "${SCENARIO_DIR}/lib.sh" scenario_start "test_basic_balances" # ── Ensure Foundry is available ────────────────────────────── -if ! command -v cast &>/dev/null; then - info "Installing Foundry …" - export SHELL=/bin/bash - curl -sSL https://foundry.paradigm.xyz | bash - export PATH="$HOME/.foundry/bin:$PATH" - "$HOME/.foundry/bin/foundryup" -fi -assert_ok command -v cast "cast is installed" +ensure_foundry # ── Read devnet info ───────────────────────────────────────── RPC_URL=$(jq_devnet '.info.lotus.host_rpc_url') @@ -38,7 +31,8 @@ for i in $(seq 0 $((USER_COUNT - 1))); do assert_gt "$FIL_WEI" 0 "${NAME} FIL balance > 0" # MockUSDFC ERC-20 balance - USDFC_WEI=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + USDFC_WEI_RAW=$(cast call "$USDFC_ADDR" "balanceOf(address)(uint256)" "$ADDR" --rpc-url "$RPC_URL") + USDFC_WEI=$(echo "$USDFC_WEI_RAW" | tr -cd '0-9') assert_gt "$USDFC_WEI" 0 "${NAME} USDFC balance > 0" done From ba8736998e582ffad7794efdd17dac52f0d25f96 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 08:52:28 +0000 Subject: [PATCH 8/9] fix: ensure_foundry was in wrong location --- scenarios/lib.sh | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scenarios/lib.sh b/scenarios/lib.sh index 5956b49..58a3d5a 100755 --- a/scenarios/lib.sh +++ b/scenarios/lib.sh @@ -34,6 +34,17 @@ # shellcheck disable=SC2034 # Variables here are used by scripts that source this file set -euo pipefail +ensure_foundry() { + if ! command -v cast &>/dev/null; then + info "Installing Foundry …" + export SHELL=/bin/bash + curl -sSL https://foundry.paradigm.xyz | bash + export PATH="$HOME/.foundry/bin:$PATH" + "$HOME/.foundry/bin/foundryup" + fi + assert_ok command -v cast "cast is installed" +} + # ── Paths ──────────────────────────────────────────────────── DEVNET_INFO="${DEVNET_INFO:-$HOME/.foc-devnet/state/latest/devnet-info.json}" SCENARIO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -76,16 +87,6 @@ assert_eq() { # assert_not_empty assert_not_empty() { # ── Foundry install (hoisted) ─────────────────────────────── - ensure_foundry() { - if ! command -v cast &>/dev/null; then - info "Installing Foundry …" - export SHELL=/bin/bash - curl -sSL https://foundry.paradigm.xyz | bash - export PATH="$HOME/.foundry/bin:$PATH" - "$HOME/.foundry/bin/foundryup" - fi - assert_ok command -v cast "cast is installed" - } if [[ -n "$1" ]]; then ok "$2"; else fail "$2 (empty)"; fi } From c28437c84498494911ecdea15074037daac8ecb5 Mon Sep 17 00:00:00 2001 From: redpanda-f Date: Tue, 3 Mar 2026 09:39:27 +0000 Subject: [PATCH 9/9] feat: latestCommit and latestTag takes a branch optionally --- .gitignore | 3 +- src/cli.rs | 28 +++++++++--- src/commands/init/config.rs | 4 +- src/commands/init/latest_resolver.rs | 67 +++++++++++++++++----------- src/config.rs | 64 ++++++++++++++------------ src/main_app/version.rs | 4 +- 6 files changed, 106 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 8a22f1c..e0a6442 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ target/ contracts/MockUSDFC/lib/ contracts/MockUSDFC/broadcast/ artifacts/ -.vscode/ \ No newline at end of file +.vscode/ +*__pycache__/ \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 993301d..c78cd32 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,13 +23,31 @@ pub enum Commands { Stop, /// Initialize foc-devnet by building and caching Docker images Init { - /// Curio source location (e.g., 'gittag:tag', 'gittag:url:tag', 'gitcommit:commit', 'gitcommit:url:commit', 'gitbranch:branch', 'gitbranch:url:branch', 'local:/path/to/curio') + /// Curio source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/curio'. #[arg(long)] curio: Option, - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/lotus') + /// Lotus source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/lotus'. #[arg(long)] lotus: Option, - /// Filecoin Services source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/filecoin-services') + /// Filecoin Services source location. + /// Magic values: 'latestTag' (newest stable tag, auto-detects branch), + /// 'latestTag:' (newest stable tag on given branch), + /// 'latestCommit' (tip of default branch), 'latestCommit:'. + /// Explicit: 'gittag:', 'gittag::', 'gitcommit:', + /// 'gitcommit::', 'gitbranch:', 'gitbranch::', + /// 'local:/path/to/filecoin-services'. #[arg(long)] filecoin_services: Option, /// Yugabyte download URL @@ -82,12 +100,12 @@ pub enum BuildCommands { pub enum ConfigCommands { /// Configure Lotus source location Lotus { - /// Lotus source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') + /// Lotus source location (e.g., 'latestTag', 'latestTag:master', 'latestCommit', 'latestCommit:main', 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus') source: String, }, /// Configure Curio source location Curio { - /// Curio source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') + /// Curio source location (e.g., 'latestTag', 'latestTag:main', 'latestCommit', 'latestCommit:main', 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio') source: String, }, } diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index b14b1ed..79c8933 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -113,8 +113,8 @@ pub fn apply_location_override( Location::GitTag { ref url, .. } => url.clone(), Location::GitCommit { ref url, .. } => url.clone(), Location::GitBranch { ref url, .. } => url.clone(), - Location::LatestCommit { ref url } => url.clone(), - Location::LatestTag { ref url } => url.clone(), + Location::LatestCommit { ref url, .. } => url.clone(), + Location::LatestTag { ref url, .. } => url.clone(), Location::LocalSource { .. } => default_url.to_string(), }; *location = Location::parse_with_default(&loc_str, &url) diff --git a/src/commands/init/latest_resolver.rs b/src/commands/init/latest_resolver.rs index dd2fa45..4e90346 100644 --- a/src/commands/init/latest_resolver.rs +++ b/src/commands/init/latest_resolver.rs @@ -9,11 +9,12 @@ //! (or `master` if `main` does not exist — no local clone needed). //! //! `LatestTag` performs a blobless bare fetch of the default branch (`main` -//! or `master`) and all -//! tags into a temporary directory, then runs `git tag --merged main` to -//! enumerate only tags whose commits are reachable from `main`. This avoids -//! picking tags that were created on feature branches or release branches that -//! diverged from `main`. +//! or `master`) and all tags into a temporary directory, then runs `git tag` +//! to enumerate all fetched tags. Pre-release tags (those with semver +//! pre-release identifiers such as `-rc1`, `-alpha`, `-beta`) are filtered +//! out, and the highest stable version is returned. The `--merged` filter is +//! deliberately avoided because projects like Lotus cut releases on separate +//! branches that are never merged back into `master`/`main`. //! //! # Example //! @@ -70,13 +71,13 @@ impl Drop for TempBareRepo { /// All other variants are returned unchanged. pub fn resolve_location(location: Location) -> Result> { match location { - Location::LatestCommit { url } => { - let commit = fetch_latest_commit(&url)?; + Location::LatestCommit { url, branch } => { + let commit = fetch_latest_commit(&url, branch.as_deref())?; info!("Resolved latestCommit for {} → {}", url, commit); Ok(Location::GitCommit { url, commit }) } - Location::LatestTag { url } => { - let tag = fetch_latest_tag(&url)?; + Location::LatestTag { url, branch } => { + let tag = fetch_latest_tag(&url, branch.as_deref())?; info!("Resolved latestTag for {} → {}", url, tag); Ok(Location::GitTag { url, tag }) } @@ -84,12 +85,16 @@ pub fn resolve_location(location: Location) -> Result Result> { - let branch = resolve_default_branch(url)?; +/// If `branch` is `None`, the default branch (`main` or `master`) is +/// auto-detected from the remote. Fails if the resolved branch is not found. +fn fetch_latest_commit( + url: &str, + branch: Option<&str>, +) -> Result> { + let branch = resolve_branch(url, branch)?; info!("Fetching latest commit on {} from {}", branch, url); let output = Command::new("git") @@ -167,19 +172,32 @@ fn resolve_default_branch(url: &str) -> Result) -> Result> { + match branch { + Some(b) => Ok(b.to_string()), + None => resolve_default_branch(url), + } +} + +/// Fetch the highest stable semver tag for a remote repo. /// /// Strategy: -/// 1. Resolve the default branch (`main`, falling back to `master`). +/// 1. Resolve the branch: use `branch` if provided, otherwise auto-detect +/// the default branch (`main` / `master`) from the remote. /// 2. Create a throwaway bare repo in a temp directory. -/// 3. Blobless-fetch the default branch and all tags from the remote (no file -/// content downloaded — only commit and tree objects). -/// 4. Run `git tag --merged ` to enumerate tags reachable from it. +/// 3. Blobless-fetch all tags from the remote (no file content downloaded). +/// 4. Run `git tag` to enumerate all fetched tags. /// 5. Filter for stable semver tags (no `-rc`, `-alpha`, etc.) and return /// the highest by numeric segment comparison. -fn fetch_latest_tag(url: &str) -> Result> { - let branch = resolve_default_branch(url)?; +/// +/// Note: We intentionally do *not* use `git tag --merged ` because +/// projects like Lotus cut releases on separate release branches that are +/// never merged back into `master`/`main`. Using `--merged` would cause the +/// resolver to return a stale version (e.g. `v1.28.1` instead of `v1.35.0`). +fn fetch_latest_tag(url: &str, branch: Option<&str>) -> Result> { + let branch = resolve_branch(url, branch)?; info!("Fetching latest stable tag on {} from {}", branch, url); let repo = TempBareRepo::create()?; @@ -187,14 +205,13 @@ fn fetch_latest_tag(url: &str) -> Result> { fetch_default_branch_and_tags(repo.path(), url, &branch)?; let tags_output = Command::new("git") - .args(["tag", "--merged", &branch]) + .args(["tag"]) .current_dir(repo.path()) .output()?; if !tags_output.status.success() { return Err(format!( - "git tag --merged {} failed: {}", - branch, + "git tag failed: {}", String::from_utf8_lossy(&tags_output.stderr).trim() ) .into()); diff --git a/src/config.rs b/src/config.rs index cccf0a6..f1ee24b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,53 +37,56 @@ pub enum Location { /// branch (e.g., "main", "develop") to check out. GitBranch { url: String, branch: String }, - /// Resolve to the latest commit on the default branch of the repository at init time. + /// Resolve to the latest commit on the given (or auto-detected default) branch at init time. /// - /// The `url` field is the Git repository URL. When used with `foc-devnet init`, - /// this is immediately resolved to a concrete `GitCommit` by querying the remote, + /// `url` is the Git repository URL. `branch` pins a specific branch; when + /// `None` the default branch (`main` / `master`) is auto-detected from the + /// remote. At init time this is immediately resolved to a concrete `GitCommit` /// so the stored config always records the exact SHA used. /// - /// Example CLI usage: `--curio latestCommit` - LatestCommit { url: String }, + /// Example CLI usage: `--curio latestCommit` or `--curio latestCommit:main` + LatestCommit { url: String, branch: Option }, - /// Resolve to the latest tag of the repository at init time. + /// Resolve to the latest stable semver tag reachable from the given (or + /// auto-detected default) branch at init time. /// - /// The `url` field is the Git repository URL. When used with `foc-devnet init`, - /// this is immediately resolved to a concrete `GitTag` by querying the remote, + /// `url` is the Git repository URL. `branch` pins a specific branch; when + /// `None` the default branch (`main` / `master`) is auto-detected from the + /// remote. At init time this is immediately resolved to a concrete `GitTag` /// so the stored config always records the exact tag used. /// - /// Example CLI usage: `--lotus latestTag` - LatestTag { url: String }, + /// Example CLI usage: `--lotus latestTag` or `--lotus latestTag:release/v2` + LatestTag { url: String, branch: Option }, } impl Location { - /// Parse a location string in the format "type:value" or "type:url:value" + /// Parse a location string in the format "type" or "type:value". /// /// Supported formats: - /// - `latestCommit` — resolves to latest HEAD commit (uses default URL) - /// - `latestTag` — resolves to latest semver tag (uses default URL) - /// - `latestCommit:url` — same, with explicit URL - /// - `latestTag:url` — same, with explicit URL - /// - `gittag:tag` — (uses default URL) - /// - `gitcommit:commit` — (uses default URL) - /// - `gitbranch:branch` — (uses default URL) - /// - `local:dir` - /// - `gittag:url:tag` - /// - `gitcommit:url:commit` - /// - `gitbranch:url:branch` - /// - /// Where url can contain colons (e.g., https://github.com/repo.git) + /// - `latestCommit` — auto-detects default branch (`main` / `master`) + /// - `latestCommit:` — uses specified branch (e.g. `latestCommit:release/v2`) + /// - `latestTag` — auto-detects default branch + /// - `latestTag:` — uses specified branch (e.g. `latestTag:master`) + /// - `gittag:` — (uses default URL) + /// - `gitcommit:` — (uses default URL) + /// - `gitbranch:` — (uses default URL) + /// - `local:` + /// - `gittag::` + /// - `gitcommit::` + /// - `gitbranch::` pub fn parse_with_default(s: &str, default_url: &str) -> Result { - // Handle bare magic keywords (no colon) — use default URL + // Handle bare magic keywords (no colon) — auto-detect branch match s { "latestCommit" => { return Ok(Location::LatestCommit { url: default_url.to_string(), + branch: None, }) } "latestTag" => { return Ok(Location::LatestTag { url: default_url.to_string(), + branch: None, }) } _ => {} @@ -92,7 +95,8 @@ impl Location { let parts: Vec<&str> = s.split(':').collect(); if parts.len() < 2 { return Err(format!( - "Invalid location format: {}. Expected 'type:value', 'latestCommit', or 'latestTag'", + "Invalid location format: '{}'. Expected 'latestCommit', 'latestTag', \ + 'latestCommit:', 'latestTag:', or 'gittag/gitcommit/gitbranch/local:...'", s )); } @@ -101,12 +105,14 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { - // latestCommit:url and latestTag:url forms + // latestCommit: and latestTag: "latestCommit" => Ok(Location::LatestCommit { - url: remaining.to_string(), + url: default_url.to_string(), + branch: Some(remaining.to_string()), }), "latestTag" => Ok(Location::LatestTag { - url: remaining.to_string(), + url: default_url.to_string(), + branch: Some(remaining.to_string()), }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), diff --git a/src/main_app/version.rs b/src/main_app/version.rs index a881012..e42f75a 100644 --- a/src/main_app/version.rs +++ b/src/main_app/version.rs @@ -70,10 +70,10 @@ fn print_location_info(label: &str, location: &Location) { Location::GitBranch { url, branch } => { info!("{}: {}, branch {}", label, url, branch); } - Location::LatestCommit { url } => { + Location::LatestCommit { url, .. } => { info!("{}: {}, latest commit (unresolved)", label, url); } - Location::LatestTag { url } => { + Location::LatestTag { url, .. } => { info!("{}: {}, latest tag (unresolved)", label, url); } }