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"
+