commit 85fbf0dbebf65b98b33584f7961a78cd2c555ce7
parent 19b29d71693d85d174b8ee279e15b4f5d114f8c1
Author: triesap <tyson@radroots.org>
Date: Wed, 25 Feb 2026 16:29:36 +0000
ci: add release preflight automation lane
- add manual github workflow for release-preflight execution
- add deterministic preflight script for check validate and coverage gates
- add publish-order script with dry-run dependency deferral handling
- validate shell scripts with bash -n before commit
Diffstat:
3 files changed, 160 insertions(+), 0 deletions(-)
diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml
@@ -0,0 +1,37 @@
+name: release-preflight
+
+on:
+ workflow_dispatch:
+
+jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: guard committed ts artifacts
+ run: ./scripts/ci/guard_committed_ts_artifacts.sh
+
+ - name: install rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: 1.92.0
+
+ - name: install nightly rust toolchain
+ run: rustup toolchain install nightly --profile minimal
+
+ - name: install cargo llvm-cov
+ uses: taiki-e/install-action@cargo-llvm-cov
+
+ - name: run release preflight
+ run: ./scripts/ci/release_preflight.sh
+
+ - name: upload release preflight artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-preflight
+ path: |
+ target/coverage/coverage-refresh.tsv
+ target/coverage/coverage-refresh-status.tsv
+ target/coverage/**/gate-report.json
diff --git a/scripts/ci/release_preflight.sh b/scripts/ci/release_preflight.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$root_dir"
+
+cargo check -q
+cargo test -q -p xtask
+cargo run -q -p xtask -- sdk validate
+
+required_file="$(mktemp)"
+trap 'rm -f "$required_file"' EXIT
+cargo run -q -p xtask -- sdk coverage required-crates > "$required_file"
+
+mkdir -p target/coverage
+printf "crate\tstatus\texec\tfunc\tbranch\treport\n" > target/coverage/coverage-refresh.tsv
+printf "crate\tstatus\n" > target/coverage/coverage-refresh-status.tsv
+
+while IFS= read -r crate; do
+ [ -n "$crate" ] || continue
+ safe_crate="${crate//-/_}"
+ out_dir="target/coverage/${safe_crate}"
+ mkdir -p "$out_dir"
+
+ cargo run -q -p xtask -- sdk coverage run-crate --crate "$crate" --out "$out_dir" --test-threads 1
+ cargo run -q -p xtask -- sdk coverage report \
+ --scope "${crate}" \
+ --summary "${out_dir}/coverage-summary.json" \
+ --lcov "${out_dir}/coverage-lcov.info" \
+ --out "${out_dir}/gate-report.json" \
+ --fail-under-exec-lines 100 \
+ --fail-under-functions 100 \
+ --fail-under-branches 100 \
+ --require-branches
+
+ printf "%s\tpass\t100.0\t100.0\t100.0\t%s\n" "$crate" "${out_dir}/gate-report.json" >> target/coverage/coverage-refresh.tsv
+ printf "%s\tpass\n" "$crate" >> target/coverage/coverage-refresh-status.tsv
+done < "$required_file"
+
+cargo run -q -p xtask -- sdk release preflight
+echo "release preflight complete"
diff --git a/scripts/ci/release_publish_order.sh b/scripts/ci/release_publish_order.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$root_dir"
+
+mode="${1:-publish}"
+if [[ "$mode" != "publish" && "$mode" != "dry-run" ]]; then
+ echo "usage: scripts/ci/release_publish_order.sh [publish|dry-run]"
+ exit 2
+fi
+
+release_version="$(
+ awk '
+ /^\[release\]/ { in_release = 1; next }
+ in_release && /^version = / {
+ gsub(/"/, "", $3);
+ print $3;
+ exit
+ }
+ ' contract/release/publish-set.toml
+)"
+
+if [[ -z "$release_version" ]]; then
+ echo "failed to resolve release.version from contract/release/publish-set.toml"
+ exit 1
+fi
+
+order_file="$(mktemp)"
+trap 'rm -f "$order_file"' EXIT
+
+awk '
+ /^\[publish_order\]/ { in_order = 1; next }
+ /^\[/ && in_order { exit }
+ in_order && /"/ {
+ line = $0
+ gsub(/[" ,]/, "", line)
+ if (length(line) > 0) print line
+ }
+' contract/release/publish-set.toml > "$order_file"
+
+if [[ ! -s "$order_file" ]]; then
+ echo "publish_order.crates list is empty"
+ exit 1
+fi
+
+while IFS= read -r crate; do
+ [ -n "$crate" ] || continue
+ if [[ "$mode" == "dry-run" ]]; then
+ log_file="$(mktemp)"
+ if cargo publish --dry-run --locked --allow-dirty -p "$crate" >"$log_file" 2>&1; then
+ cat "$log_file"
+ rm -f "$log_file"
+ continue
+ fi
+
+ missing_dep="$(sed -n 's/.*no matching package named `\([^`]*\)`.*/\1/p' "$log_file" | head -n1)"
+ if [[ -n "$missing_dep" ]] && grep -Fxq "$missing_dep" "$order_file"; then
+ echo "dry-run defer for ${crate}: dependency ${missing_dep} is not yet published"
+ rm -f "$log_file"
+ continue
+ fi
+
+ cat "$log_file"
+ rm -f "$log_file"
+ exit 1
+ fi
+
+ cargo publish --locked -p "$crate"
+ for attempt in $(seq 1 30); do
+ if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${release_version}" >/dev/null 2>&1; then
+ break
+ fi
+ if [[ "$attempt" == "30" ]]; then
+ echo "crate ${crate} version ${release_version} not visible on crates.io after publish"
+ exit 1
+ fi
+ sleep 10
+ done
+done < "$order_file"
+
+echo "publish sequence complete for release ${release_version}"