diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab9b606 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +GITEA_API = +GITEA_TOKEN = diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml index d1eaf37..3c798e7 100644 --- a/.gitea/workflows/pipeline.yml +++ b/.gitea/workflows/pipeline.yml @@ -27,6 +27,35 @@ jobs: - name: Verify (formatting, lint, tests, docs) run: pixi run check + docs: + needs: check + if: ${{ (gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/'))) || gitea.event_name == 'workflow_dispatch' }} + runs-on: pixi-build-rust + steps: + - uses: actions/checkout@v4 + + - name: Build the API docs + env: + RUSTDOCFLAGS: "--html-before-content ${{ gitea.workspace }}/scripts/docs-banner.html" + run: | + pixi install --locked + pixi run doc-build + + - name: Redirect the channel root to the crate page + run: | + printf '%s\n' "" > target/doc/index.html + + - name: Publish the channel to /srv/www + run: | + case "${{ gitea.ref }}" in + refs/tags/*) channel=release ;; + *) channel=nightly ;; + esac + bash scripts/publish-docs.sh "$channel" + + - name: Point the site root at the latest release, else nightly + run: bash scripts/redirect-docs.sh + nightly: needs: check if: ${{ (gitea.event_name == 'push' && gitea.ref == 'refs/heads/main') || gitea.event_name == 'schedule' || gitea.event_name == 'workflow_dispatch' }} @@ -42,7 +71,7 @@ jobs: - name: Install the pinned toolchain run: pixi install - - name: Build the release binary + - name: Build and stage the bundle run: | COURSEBANK_VERSION="$(bash scripts/nightly-version.sh)" export COURSEBANK_VERSION @@ -94,7 +123,7 @@ jobs: pixi run dist - name: Package - run: bash scripts/package.sh "coursebank-$(bash scripts/version.sh)-linux-x64.tar.gz" dist + run: bash scripts/package.sh "coursebank-$(bash scripts/version.sh)-$(bash scripts/platform-tag.sh).tar.gz" dist - name: Publish the release env: @@ -104,35 +133,6 @@ jobs: TAG: ${{ gitea.ref_name }} BODY: See CHANGELOG.md. run: | - asset="coursebank-$(bash scripts/version.sh)-linux-x64.tar.gz" + asset="coursebank-$(bash scripts/version.sh)-$(bash scripts/platform-tag.sh).tar.gz" export ASSETS="$asset $asset.sha256" bash scripts/gitea-release.sh - - docs: - needs: check - if: ${{ (gitea.event_name == 'push' && (gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/'))) || gitea.event_name == 'workflow_dispatch' }} - runs-on: pixi-build-rust - steps: - - uses: actions/checkout@v4 - - - name: Build the API docs - env: - RUSTDOCFLAGS: "--html-before-content ${{ gitea.workspace }}/scripts/docs-banner.html" - run: | - pixi install --locked - pixi run doc-build - - - name: Redirect the channel root to the crate page - run: | - printf '%s\n' "" > target/doc/index.html - - - name: Publish the channel to /srv/www - run: | - case "${{ gitea.ref }}" in - refs/tags/*) channel=release ;; - *) channel=nightly ;; - esac - bash scripts/publish-docs.sh "$channel" - - - name: Point the site root at the latest release, else nightly - run: bash scripts/redirect-docs.sh diff --git a/.gitignore b/.gitignore index 8181a1e..d650d90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ preview /dist/ /THIRD-PARTY-LICENSES.txt +.env + # pixi environments .pixi/* !.pixi/config.toml diff --git a/pixi.toml b/pixi.toml index c0f76be..762b8a1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -5,66 +5,97 @@ authors = ["Scientific Computing Studio "] channels = ["conda-forge"] platforms = ["linux-64", "osx-arm64", "osx-64"] -[dependencies] -rust = ">=1.96.0,<1.97" -c-compiler = "*" -pkg-config = "*" - - -[tasks] - -# --- Build and run --- -build = { cmd = "cargo build --release", description = "Compile the release binary at target/release/coursebank" } -debug = { cmd = "cargo build", description = "Compile the debug binary" } -build-lean = { cmd = "cargo build --release --no-default-features", description = "Release build without the parquet feature" } -install = { cmd = "cargo install --path . --locked", description = "Install the coursebank binary onto your PATH" } -clean = { cmd = "cargo clean", description = "Remove the target directory" } -cb = { cmd = "cargo run --release --quiet --", description = "Run the CLI, e.g., `pixi run cb validate -C path/to/course`" } - -# --- Quality gate --- -format = { cmd = "cargo fmt --all", description = "Reformat the source in place" } -fmt-check = { cmd = "cargo fmt --all --check", description = "Fail if the source is not formatted; does not rewrite" } -lint = { cmd = "cargo clippy --all-targets -- -D warnings", description = "Run clippy with warnings treated as errors" } -tests = { cmd = "cargo test", description = "Run all tests: unit, integration, and doctests" } -doctests = { cmd = "cargo test --doc", description = "Run only the documentation examples" } -check-docs = { cmd = "cargo test --test docs", description = "Check that included markdown fences declare a language" } -check = { depends-on = ["fmt-check", "lint", "tests", "doctests", "check-docs", "doc-build"], description = "Full verify-only gate: formatting, lint, tests, and doc build" } - -# --- Documentation --- -doc = { cmd = "cargo doc --no-deps --open", depends-on = ["check-docs"], description = "Build the API docs and open them in a browser" } -doc-build = { cmd = "cargo doc --no-deps", depends-on = ["check-docs"], description = "Build the API docs without opening a browser" } - -# --- Release packaging --- -setup-tools = { cmd = "cargo install cargo-about --locked --features cli", description = "Install cargo-about, which the licenses task needs" } -licenses = { cmd = "cargo about generate about.hbs -o THIRD-PARTY-LICENSES.txt", description = "Regenerate THIRD-PARTY-LICENSES.txt" } -dist = { cmd = "mkdir -p dist && cp target/release/coursebank dist/ && cp LICENSE.md THIRD-PARTY-LICENSES.txt dist/", depends-on = ["build", "licenses"], description = "Assemble a release bundle (binary and license notices) in dist/" } - [environments] dev = ["dev"] docs = ["docs"] release = ["release"] -# Developer tooling: editor support, a debugger, a faster test runner, and a -# file watcher. +[dependencies] +rust = ">=1.96.0,<1.97" +c-compiler = "*" +pkg-config = "*" + +# --- Build and run --- +[tasks.build] +description = "Compile the release binary at target/release/coursebank" +cmd = "cargo build --release" +[tasks.debug] +description = "Compile the debug binary" +cmd = "cargo build" +[tasks.build-lean] +description = "Release build without the parquet feature" +cmd = "cargo build --release --no-default-features" +[tasks.install] +description = "Install the coursebank binary onto your PATH" +cmd = "cargo install --path . --locked" +[tasks.clean] +description = "Remove the target directory" +cmd = "cargo clean" +[tasks.cb] +description = "Run the CLI, e.g., `pixi run cb validate -C path/to/course`" +cmd = "cargo run --release --quiet --" + +# --- Quality gate --- +[tasks.format] +description = "Reformat the source in place" +cmd = "cargo fmt --all" +[tasks.fmt-check] +description = "Fail if the source is not formatted; does not rewrite" +cmd = "cargo fmt --all --check" +[tasks.lint] +description = "Run clippy with warnings treated as errors" +cmd = "cargo clippy --all-targets -- -D warnings" +[tasks.tests] +description = "Run all tests: unit, integration, and doctests" +cmd = "cargo test" +[tasks.doctests] +description = "Run only the documentation examples" +cmd = "cargo test --doc" +[tasks.check-docs] +description = "Check that included markdown fences declare a language" +cmd = "cargo test --test docs" +[tasks.check] +description = "Full verify-only gate: formatting, lint, tests, and doc build" +depends-on = ["fmt-check", "lint", "tests", "doctests", "check-docs", "doc-build"] + +# --- Documentation --- +[tasks.doc] +description = "Build the API docs and open them in a browser" +depends-on = ["check-docs"] +cmd = "cargo doc --no-deps --open" +[tasks.doc-build] +description = "Build the API docs without opening a browser" +depends-on = ["check-docs"] +cmd = "cargo doc --no-deps" + +# --- Release packaging --- +[tasks.setup-tools] +description = "Install cargo-about, which the licenses task needs" +cmd = "cargo install cargo-about --locked --features cli" +[tasks.licenses] +description = "Regenerate THIRD-PARTY-LICENSES.txt" +cmd = "cargo about generate about.hbs -o THIRD-PARTY-LICENSES.txt" +[tasks.dist] +description = "Assemble a release bundle (binary and license notices) in dist/" +cmd = "mkdir -p dist && cp target/release/coursebank dist/ && cp LICENSE.md THIRD-PARTY-LICENSES.txt dist/" +depends-on = ["build", "licenses"] + [feature.dev.dependencies] rust-analyzer = "*" lldb = "*" cargo-nextest = "*" -[feature.dev.tasks] -nextest = { cmd = "cargo nextest run", description = "Run the test suite with nextest" } +[feature.dev.tasks.nextest] +description = "Run the test suite with nextest" +cmd = "cargo nextest run" -# Typst is only needed to typeset exported .typ files by hand. The exporter -# writes .typ on its own and does not require typst to be installed. [feature.docs.dependencies] typst = "*" -[feature.docs.tasks] -typeset = { cmd = "typst compile", description = "Compile a .typ file to PDF" } +[feature.docs.tasks.typeset] +description = "Compile a .typ file to PDF" +cmd = "typst compile" -# Release tooling, kept in its own environment so the default and dev -# environments do not pull in Python. bump-my-version reads .bumpversion.toml, -# which encodes the YY.MM.PATCH scheme. [feature.release.dependencies] bump-my-version = "*" diff --git a/scripts/attach-asset.sh b/scripts/attach-asset.sh new file mode 100755 index 0000000..43ffe90 --- /dev/null +++ b/scripts/attach-asset.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Attach files to an existing Gitea release, found by tag. Re-runnable: an +# asset of the same name is replaced. Use this to add a build CI can't produce +# (e.g., the macOS arm64 bundle, built on a Mac) to a release CI already made. +# +# Required env: +# GITEA_API e.g., https://git.scient.ing/api/v1/repos// +# GITEA_TOKEN a token with contents:write +# TAG the release tag the assets belong to +# ASSETS space-separated list of files to upload +# Optional env: +# JQ jq command (default: jq); on a Mac without jq, set to +# "pixi exec --spec jq -- jq" + +set -euo pipefail +: "${GITEA_API:?}"; : "${GITEA_TOKEN:?}"; : "${TAG:?}"; : "${ASSETS:?}" +: "${JQ:=jq}" +auth="Authorization: token ${GITEA_TOKEN}" + +rid="$(curl -fsSL -H "$auth" "${GITEA_API}/releases/tags/${TAG}" | $JQ -r '.id')" +[ -n "$rid" ] && [ "$rid" != "null" ] || { echo "no release found for tag ${TAG}" >&2; exit 1; } + +for f in $ASSETS; do + n="$(basename "$f")" + for aid in $(curl -fsSL -H "$auth" "${GITEA_API}/releases/${rid}/assets" \ + | $JQ -r --arg n "$n" '.[] | select(.name==$n) | .id'); do + curl -fsSL -X DELETE -H "$auth" "${GITEA_API}/releases/${rid}/assets/${aid}" || true + done + echo "uploading $n to release ${TAG}" + curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" \ + "${GITEA_API}/releases/${rid}/assets?name=${n}" +done + diff --git a/scripts/check-tag-version.sh b/scripts/check-tag-version.sh new file mode 100755 index 0000000..ad87bcf --- /dev/null +++ b/scripts/check-tag-version.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Fail unless the release tag matches the crate version. +# Env: TAG (a leading "v" is tolerated). Run from the repository root. + +set -euo pipefail +: "${TAG:?set TAG to the tag being released}" + +here="$(cd "$(dirname "$0")" && pwd)" +tag="${TAG#v}" +version="$(bash "$here/version.sh")" + +if [ "$tag" != "$version" ]; then + echo "tag ${TAG} does not match Cargo.toml version ${version}" >&2 + exit 1 +fi + +echo "tag ${TAG} matches crate version ${version}" + diff --git a/scripts/dist-attach.sh b/scripts/dist-attach.sh new file mode 100755 index 0000000..b89856f --- /dev/null +++ b/scripts/dist-attach.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the host-native release bundle and attach it to an existing Gitea +# release. Meant for targets CI cannot build. Check out +# the tagged commit first, so the crate version matches the tag. +# +# Required env: GITEA_API, GITEA_TOKEN (contents:write). +# Optional env: TAG (defaults to the crate version), JQ. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +: "${GITEA_API:?set GITEA_API, e.g., https://git.scient.ing/api/v1/repos//}" +: "${GITEA_TOKEN:?set GITEA_TOKEN to a token with contents:write}" + +version="$(bash "$here/version.sh")" +tag="${TAG:-$version}" +asset="coursebank-${version}-$(bash "$here/platform-tag.sh").tar.gz" + +echo "building ${asset} for release ${tag}" + +pixi run setup-tools +pixi run dist +bash "$here/package.sh" "$asset" dist +TAG="$tag" ASSETS="$asset $asset.sha256" bash "$here/attach-asset.sh" + +echo "done: ${asset} attached to ${tag}" + diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh index a59078f..3e98fa2 100755 --- a/scripts/gitea-release.sh +++ b/scripts/gitea-release.sh @@ -2,7 +2,7 @@ # Create (or replace) a Gitea release and upload assets, using the Gitea API. # # Required env: -# GITEA_API e.g. https://git.scient.ing/api/v1/repos// +# GITEA_API e.g., https://git.scient.ing/api/v1/repos// # GITEA_TOKEN a token with contents:write # TAG the release tag # ASSETS space-separated list of files to attach @@ -14,7 +14,9 @@ # TARGET_COMMITISH (default: empty; Gitea uses the default branch) # JQ jq command (default: jq); set to e.g. "pixi exec --spec jq -- jq" # DRY_RUN set to anything to print actions instead of calling the API + set -euo pipefail + : "${GITEA_API:?}"; : "${GITEA_TOKEN:?}"; : "${TAG:?}"; : "${ASSETS:?}" : "${JQ:=jq}" name="${RELEASE_NAME:-$TAG}" @@ -24,6 +26,7 @@ replace="${REPLACE:-false}" target="${TARGET_COMMITISH:-}" auth="Authorization: token ${GITEA_TOKEN}" dry="${DRY_RUN:-}" + say() { echo "[gitea-release] $*"; } if [ "$replace" = "true" ]; then @@ -66,4 +69,6 @@ for f in $ASSETS; do "${GITEA_API}/releases/${rid}/assets?name=${n}" fi done + say "done" + diff --git a/scripts/nightly-version.sh b/scripts/nightly-version.sh index c7e592a..60e8189 100755 --- a/scripts/nightly-version.sh +++ b/scripts/nightly-version.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash + # Print an auto-generated nightly version string derived from git history: # # +.g e.g., 26.8.0+14.gb1a2c3d4 @@ -12,14 +13,15 @@ # and never sorts ahead of the release it is based on. Needs full history and # tags (check out with fetch-depth: 0 and fetch-tags: true). Release tags are # matched as N.N.N; the moving `nightly` tag is ignored. + set -euo pipefail sha="$(git rev-parse --short=8 HEAD)" if desc="$(git describe --tags --long \ --match '[0-9]*.[0-9]*.[0-9]*' --exclude nightly 2>/dev/null)"; then # desc is --g; the tag itself has no '-', so strip from the right. - rest="${desc%-g*}" # - - n="${rest##*-}" # - tag="${rest%-*}" # + rest="${desc%-g*}" + n="${rest##*-}" + tag="${rest%-*}" echo "${tag}+${n}.g${sha}" else n="$(git rev-list --count HEAD)" diff --git a/scripts/package.sh b/scripts/package.sh index e7320ec..3d3ddfa 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash # Tar the contents of a directory into an asset and write its checksum. # Usage: package.sh + set -euo pipefail + asset="${1:?usage: package.sh }" dir="${2:?usage: package.sh }" + tar -czf "$asset" -C "$dir" . sha256sum "$asset" > "$asset.sha256" + echo "packaged $asset ($(wc -c < "$asset") bytes) and $asset.sha256" diff --git a/scripts/platform-tag.sh b/scripts/platform-tag.sh new file mode 100755 index 0000000..8eb6c83 --- /dev/null +++ b/scripts/platform-tag.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Print a short platform tag for asset names, derived from the host: +# linux-x64, linux-arm64, macos-arm64, macos-x64, ... + +set -euo pipefail + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=macos ;; + *) os="$(uname -s | tr '[:upper:]' '[:lower:]')" ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) arch="$(uname -m)" ;; +esac + +echo "${os}-${arch}" + diff --git a/scripts/redirect-docs.sh b/scripts/redirect-docs.sh index 6f70a90..b5951fc 100755 --- a/scripts/redirect-docs.sh +++ b/scripts/redirect-docs.sh @@ -3,13 +3,18 @@ # rustdoc serves the crate under /, so the target includes that segment. # Env: WWW_ROOT (default /srv/www), SITE_SLUG (default coursebank), # CRATE (default coursebank). + set -euo pipefail + root="${WWW_ROOT:-/srv/www}/${SITE_SLUG:-coursebank}" crate="${CRATE:-coursebank}" + if [ -e "$root/release/$crate/index.html" ]; then target="release/$crate/" else target="nightly/$crate/" fi + printf '%s\n' "" > "$root/index.html" echo "site root -> $target" +