diff --git a/.githooks/install.sh b/.githooks/install.sh new file mode 100755 index 00000000..f516fca4 --- /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 00000000..64796057 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,108 @@ +#!/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 ────────────────────────────────────────────── +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 + echo "$STAGED_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 + + # SC — 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 3a82ffc3..0fbb0f0a 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 - foc-start-test: + 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 scenarios/ are executable + - name: Check executable permissions + run: | + 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 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 scenarios/ + + foc-devnet-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 @@ -151,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}" @@ -346,6 +402,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 scenarios/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/.gitignore b/.gitignore index 8a22f1cf..e0a64421 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/Cargo.lock b/Cargo.lock index f195c515..1665eacf 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 d3b62ba1..ef8976af 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/README_ADVANCED.md b/README_ADVANCED.md index 58375405..1489c903 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/scenarios/lib.sh b/scenarios/lib.sh new file mode 100755 index 00000000..58a3d5ae --- /dev/null +++ b/scenarios/lib.sh @@ -0,0 +1,129 @@ +#!/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 scenarios/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 scenarios/test_.sh +# 4. Run: bash scenarios/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 + +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)" +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() { + 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" "$*"; } +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() { + # ── Foundry install (hoisted) ─────────────────────────────── + 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/scenarios/order.sh b/scenarios/order.sh new file mode 100755 index 00000000..2a99c891 --- /dev/null +++ b/scenarios/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 scenarios/. +# 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/scenarios/run.sh b/scenarios/run.sh new file mode 100755 index 00000000..3509109e --- /dev/null +++ b/scenarios/run.sh @@ -0,0 +1,151 @@ +#!/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 scenarios/run.sh # run all scenarios +# cat ~/.foc-devnet/state/latest/scenario_*.md # read the report +# +# Run a single scenario: +# bash scenarios/test_containers.sh +# +# Override devnet-info path (e.g. an older run): +# DEVNET_INFO=~/.foc-devnet/state//devnet-info.json \ +# bash scenarios/run.sh +# +# File a GitHub issue on failure (needs `gh` CLI + auth): +# REPORTING=true bash scenarios/run.sh +# +# Always file an issue, even on success: +# REPORTING=true SKIP_REPORT_ON_PASS=false bash scenarios/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 ──────────────────────────────────────────────── +# 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" + +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/scenarios/test_basic_balances.sh b/scenarios/test_basic_balances.sh new file mode 100755 index 00000000..66d0f87f --- /dev/null +++ b/scenarios/test_basic_balances.sh @@ -0,0 +1,39 @@ +#!/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 "test_basic_balances" + +# ── Ensure Foundry is available ────────────────────────────── +ensure_foundry + +# ── 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_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 + +scenario_end diff --git a/scenarios/test_containers.sh b/scenarios/test_containers.sh new file mode 100755 index 00000000..7be80f90 --- /dev/null +++ b/scenarios/test_containers.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# test_containers.sh +# +# Verifies that all foc-* containers reported in devnet-info.json +# 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 "test_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 + +# 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") + assert_eq "$STATUS" "running" "container ${cname} is running" +done + +# ── 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 + [[ "$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 diff --git a/src/cli.rs b/src/cli.rs index 993301dc..c78cd32d 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/build/repository.rs b/src/commands/build/repository.rs index 7520f032..b2d0b837 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 82966f3f..79c89334 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 00000000..4e903462 --- /dev/null +++ b/src/commands/init/latest_resolver.rs @@ -0,0 +1,364 @@ +//! 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` +//! 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 +//! +//! ```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, branch } => { + let commit = fetch_latest_commit(&url, branch.as_deref())?; + info!("Resolved latestCommit for {} → {}", url, commit); + Ok(Location::GitCommit { url, commit }) + } + Location::LatestTag { url, branch } => { + let tag = fetch_latest_tag(&url, branch.as_deref())?; + info!("Resolved latestTag for {} → {}", url, tag); + Ok(Location::GitTag { url, tag }) + } + other => Ok(other), + } +} + +/// Fetch the SHA of the tip of the given branch (or the auto-detected default +/// branch) on a remote. +/// +/// 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") + .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()) + } +} + +/// Return `branch` if explicitly provided, otherwise auto-detect the default +/// branch (`main` / `master`) from the remote. +fn resolve_branch(url: &str, branch: Option<&str>) -> 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 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 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. +/// +/// 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()?; + + fetch_default_branch_and_tags(repo.path(), url, &branch)?; + + let tags_output = Command::new("git") + .args(["tag"]) + .current_dir(repo.path()) + .output()?; + + if !tags_output.status.success() { + return Err(format!( + "git tag failed: {}", + 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 25b0932e..e42338cf 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 a23fa392..fbdb7d51 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 20a92e58..d0422378 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 b6fd59e3..2b4571c9 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 85b3e822..f1ee24bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,26 +36,67 @@ 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 given (or auto-detected default) branch at init time. + /// + /// `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` or `--curio latestCommit:main` + LatestCommit { url: String, branch: Option }, + + /// Resolve to the latest stable semver tag reachable from the given (or + /// auto-detected default) branch at init time. + /// + /// `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` 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: - /// - "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) — 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, + }) + } + _ => {} + } + 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 'latestCommit', 'latestTag', \ + 'latestCommit:', 'latestTag:', or 'gittag/gitcommit/gitbranch/local:...'", s )); } @@ -64,6 +105,15 @@ impl Location { let remaining = &parts[1..].join(":"); match location_type { + // latestCommit: and latestTag: + "latestCommit" => Ok(Location::LatestCommit { + url: default_url.to_string(), + branch: Some(remaining.to_string()), + }), + "latestTag" => Ok(Location::LatestTag { + url: default_url.to_string(), + branch: Some(remaining.to_string()), + }), "local" => Ok(Location::LocalSource { dir: remaining.to_string(), }), @@ -107,7 +157,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 a0c6a576..e42f75ae 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); + } } }