diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..4ba4fb5 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,216 @@ +# Continuous Integration + +This directory contains tools used by crates in the `rust-bitcoin` org to implement Continuous +Integration. Currently this is just a script `run_task.sh` that can be called from a GitHub workflow +job to run a specific task. + +TL;DR `./run_task.sh --help` + +#### Table Of Contents + +- [Usage](#usage) +- [Lock file](#lock-file) +- [Crates](#crates) + * [Per crate environment variables](#per-crate-environment-variables) + * [Additional crate specific tests](#additional-crate-specific-tests) +- [Fuzzing](#fuzzing) +- [Example workflows](#example-workflows) + * [A job using a stable toolchain](#a-job-using-a-stable-toolchain) + * [A job using a specific nightly toolchain](#a-job-using-a-specific-nightly-toolchain) + +## Usage + +The `run_task.sh` script expects a few things to be present when it runs: + +In the repository root: + +- A lock file `Cargo.lock` +- A script that defines the crates: `contrib/crates.sh` + +And for each crate: + +- `test_vars.sh` +- Optional: `extra_tests.sh` + +(See [Crates`](#crates) below.) + +## Lock file + +Repositories MUST contain a `Cargo.lock` file before running `run_task.sh`. `cargo` is typically +called with `--locked`. If you don't care about dependency versions just run `cargo update` in your +CI job (to create a lock file) before calling `run_task.sh`. + +If you do care about versions consider adding: + +- `Cargo-recent.lock`: A manifest with some recent versions numbers that pass CI. +- `Cargo-minimal.lock`: A manifest with some minimal version numbers that pass CI. + +Then you can use, for example: + +```yaml + strategy: + matrix: + dep: [minimal, recent] + steps: + + + + - name: "Copy lock file" + run: cp Cargo-${{ matrix.dep }}.lock Cargo.lock + +``` + +(Tip: Create minimal lock file with`cargo +nightly build -- -Z minimal-versions`.) + +## Crates + +All repositories MUST include a `contrib/crates.sh` script that declares the crates to be tested. + +This is a `bash` array (if repository is not a workspace just use `CRATES=(".")`). + +```bash +#!/usr/bin/env bash + +# Crates in this workspace to test (note "fuzz" is only built not tested). +CRATES=("base58" "bitcoin" "fuzz" "hashes" "internals" "io" "units") +``` + +### Per crate environment variables + +All crates MUST include a file `contrib/test_vars.sh` + +```bash +#!/usr/bin/env bash + +# Test all these features with "std" enabled. +# +# Ignore this if crate does not have "std" feature. +FEATURES_WITH_STD="" + +# Test all these features without "std" enabled. +# +# Use this even if crate does not have "std" feature. +FEATURES_WITHOUT_STD="" + +# Run these examples. +EXAMPLES="" +``` + +Tip: For non-worspace repositories this file should be in `contrib/` in the repository root. + +#### The `EXAMPLES` variable + +```bash +EXAPMLES="example:feature" +``` + +```bash +EXAPMLES="example:feature1,feature2" +``` + +```bash +EXAPMLES="example_a:feature1,feature2 example_b:feature1" +``` + + +Tip: if your example does not require any features consider using "default". + +```bash +EXAPMLES="example_a:default" +``` + +### Additional crate specific tests + +Additional tests can be put in an optional `contrib/extra_tests.sh` script. This script will be run +as part of the `stable`, `nightly`, and `msrv` jobs after running unit tests. + +As for other per-crate files, put it in either the `REPO/CRATE/contrib/` directory or +`REPO_DIR/contrib/` directory depending on whether this is a workspace or not. + +## Fuzzing + +Fuzz tests are expected to be in a crate called `fuzz/`. The `run_task.sh` script just builds +the fuzz crate as a sanity check. + +## Example workflows + +### A job using a stable toolchain + +To use the `run_task.sh` script you'll want to do something like this: + + +```yaml +jobs: + Stable: # 2 jobs, one per manifest. + name: Test - stable toolchain + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dep: [minimal, recent] + steps: + - name: "Checkout repo" + uses: actions/checkout@v4 + - name: "Checkout maintainer tools" + uses: actions/checkout@v4 + with: + repository: rust-bitcoin/rust-bitcoin-maintainer-tools + path: maintainer-tools + - name: "Select toolchain" + uses: dtolnay/rust-toolchain@stable + - name: "Copy lock file" + run: cp Cargo-${{ matrix.dep }}.lock Cargo.lock + - name: "Run test script" + run: ./maintainer-tools/ci/run_task.sh stable +``` + +### A job using a specific nightly toolchain + +Have a file in the repository root with the nightly toolchain version to use. + +```bash +$ cat nightly_version +nightly-2024-04-30 +``` + +And use a `Prepare` job to a set an environment variable using the file. + +```yaml +jobs: + Prepare: + runs-on: ubuntu-latest + outputs: + nightly_version: ${{ steps.read_toolchain.outputs.nightly_version }} + steps: + - name: Checkout Crate + uses: actions/checkout@v4 + - name: Read nightly version + id: read_toolchain + run: echo "nightly_version=$(cat nightly-version)" >> $GITHUB_OUTPUT + + Nightly: # 2 jobs, one per manifest. + name: Test - nightly toolchain + needs: Prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dep: [minimal, recent] + steps: + - name: "Checkout repo" + uses: actions/checkout@v4 + - name: "Checkout maintainer tools" + uses: actions/checkout@v4 + with: + repository: tcharding/rust-bitcoin-maintainer-tools + ref: 05-02-ci + path: maintainer-tools + - name: "Select toolchain" + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ needs.Prepare.outputs.nightly_version }} + - name: "Copy lock file" + run: cp Cargo-${{ matrix.dep }}.lock Cargo.lock + - name: "Run test script" + run: ./maintainer-tools/ci/run_task.sh nightly +``` \ No newline at end of file diff --git a/ci/run_task.sh b/ci/run_task.sh new file mode 100755 index 0000000..2ddb575 --- /dev/null +++ b/ci/run_task.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# +# Script used to run CI jobs, can also be used from the command line. + +set -euox pipefail + +REPO_DIR=$(git rev-parse --show-toplevel) + +# Make all cargo invocations verbose. +export CARGO_TERM_VERBOSE=true + +# Set to false to turn off verbose output. +flag_verbose=true + +# Use the current `Cargo.lock` file without updating it. +cargo="cargo --locked" + +usage() { + cat < /dev/null + cargo --locked build + popd > /dev/null + break + fi + + verbose_say "Sourcing $test_vars_script" + if [ -e "$test_vars_script" ]; then + # Set crate specific variables. + . "$test_vars_script" + else + err "Missing $test_vars_script" + fi + verbose_say "Got vars" + verbose_say "FEATURES_WITH_STD: ${FEATURES_WITH_STD:-}" + verbose_say "FEATURES_WITHOUT_STD: ${FEATURES_WITHOUT_STD:-}" + verbose_say "EXAMPLES: ${EXAMPLES:-}" + + pushd "$REPO_DIR/$crate" > /dev/null + + do_test + do_feature_matrix + + popd > /dev/null + done +} + +do_test() { + # Defaults / sanity checks + $cargo build + $cargo test + + if [ -n "${EXAMPLES+x}" ]; then + for example in $EXAMPLES; do # EXAMPLES is set in contrib/test_vars.sh + name="$(echo "$example" | cut -d ':' -f 1)" + features="$(echo "$example" | cut -d ':' -f 2)" + $cargo run --example "$name" --features="$features" + done + fi + + if [ -e ./contrib/extra_tests.sh ]; + then + ./contrib/extra_tests.sh + fi +} + +# Each crate defines its own feature matrix test so feature combinations +# can be better controlled. +do_feature_matrix() { + # rust-miniscript only: https://github.com/rust-bitcoin/rust-miniscript/issues/681 + if [ -n "${FEATURES_WITH_NO_STD+x}" ]; then + $cargo build --no-default-features --features="no-std" + $cargo test --no-default-features --features="no-std" + + loop_features "no-std" "${FEATURES_WITH_NO_STD:-}" + else + $cargo build --no-default-features + $cargo test --no-default-features + fi + + if [ -z "${FEATURES_WITH_STD+x}" ]; then + loop_features "std" "${FEATURES_WITH_STD:-}" + fi + + if [ -z "${FEATURES_WITHOUT_STD+x}" ]; then + loop_features "" "$FEATURES_WITHOUT_STD" + fi +} + +# Build with each feature as well as all combinations of two features. +# +# Usage: loop_features "std" "this-feature that-feature other" +loop_features() { + local use="${1:-}" # Allow empty string. + local features="$2" # But require features. + + # All the provided features including $use + $cargo build --no-default-features --features="$use $features" + $cargo test --no-default-features --features="$use $features" + + read -r -a array <<< "$features" + local len="${#array[@]}" + + if (( len > 1 )); then + for ((i = 0 ; i < len ; i++ )); + do + $cargo build --features="$use ${array[i]}" + $cargo test --features="$use ${array[i]}" + + if (( i < len - 1 )); then + for ((j = i + 1 ; j < len ; j++ )); + do + $cargo build --features="$use ${array[i]} ${array[j]}" + $cargo test --features="$use ${array[i]} ${array[j]}" + done + fi + done + fi +} + +# Lint the workspace. +do_lint() { + need_nightly + + # Lint various feature combinations to try and catch mistakes in feature gating. + $cargo clippy --workspace --all-targets --keep-going -- -D warnings + $cargo clippy --workspace --all-targets --all-features --keep-going -- -D warnings + $cargo clippy --workspace --all-targets --no-default-features --keep-going -- -D warnings +} + +# We should not have any duplicate dependencies. This catches mistakes made upgrading dependencies +# in one crate and not in another (e.g. upgrade bitcoin_hashes in bitcoin but not in secp). +do_dup_deps() { + # We can't use pipefail because these grep statements fail by design when there is no duplicate, + # the shell therefore won't pick up mistakes in your pipe - you are on your own. + set +o pipefail + + duplicate_dependencies=$( + # Only show the actual duplicated deps, not their reverse tree, then + # whitelist the 'syn' crate which is duplicated but it's not our fault. + cargo tree --target=all --all-features --duplicates \ + | grep '^[0-9A-Za-z]' \ + | grep -v 'syn' \ + | wc -l + ) + if [ "$duplicate_dependencies" -ne 0 ]; then + echo "Dependency tree is broken, contains duplicates" + cargo tree --target=all --all-features --duplicates + exit 1 + fi + + set -o pipefail +} + +# Build the docs with a nightly toolchain, in unison with the function +# below this checks that we feature guarded docs imports correctly. +build_docs_with_nightly_toolchain() { + need_nightly + RUSTDOCFLAGS="--cfg docsrs -D warnings -D rustdoc::broken-intra-doc-links" $cargo doc --all-features +} + +# Build the docs with a stable toolchain, in unison with the function +# above this checks that we feature guarded docs imports correctly. +build_docs_with_stable_toolchain() { + local cargo="cargo +stable --locked" # Can't use global because of `+stable`. + RUSTDOCFLAGS="-D warnings" $cargo doc --all-features +} + +# Bench only works with a non-stable toolchain (nightly, beta). +do_bench() { + verbose_say "Running bench tests for: $CRATES" + + for crate in "${CRATES[@]}"; do + pushd "$REPO_DIR/$crate" > /dev/null + # Unit tests are ignored so if there are no bench test then this will just succeed. + RUSTFLAGS='--cfg=bench' cargo bench + popd > /dev/null + done +} + +# Check all the commands we use are present in the current environment. +check_required_commands() { + need_cmd cargo + need_cmd rustc + need_cmd jq + need_cmd cut + need_cmd grep + need_cmd wc +} + +say() { + echo "run_task: $1" +} + +say_err() { + say "$1" >&2 +} + +verbose_say() { + if [ "$flag_verbose" = true ]; then + say "$1" + fi +} + +err() { + echo "$1" >&2 + exit 1 +} + +need_cmd() { + if ! command -v "$1" > /dev/null 2>&1 + then err "need '$1' (command not found)" + fi +} + +need_nightly() { + cargo_ver=$(cargo --version) + if echo "$cargo_ver" | grep -q -v nightly; then + err "Need a nightly compiler; have $(cargo --version)" + fi +} + +# +# Main script +# +main "$@" +exit 0