Compare commits
7
Commits
673a11b320
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7a0d5873
|
||
|
|
fdc53ae210
|
||
|
|
489eca8856
|
||
|
|
463e320b31
|
||
|
|
5515567f17
|
||
|
|
32e1dc1101
|
||
|
|
28d63c14e3
|
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.bumpversion]
|
[tool.bumpversion]
|
||||||
current_version = "26.8.0"
|
current_version = "0.0.0"
|
||||||
parse = """(?x)
|
parse = """(?x)
|
||||||
(?P<release>
|
(?P<release>
|
||||||
(?:[1-9][0-9]?)\\. # YY short year, no leading zero
|
(?:[1-9][0-9]?)\\. # YY short year, no leading zero
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
GITEA_API =
|
||||||
|
GITEA_TOKEN =
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: pixi-build-rust
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install the pinned toolchain
|
|
||||||
run: pixi install
|
|
||||||
|
|
||||||
- name: Verify (formatting, lint, tests, docs)
|
|
||||||
run: pixi run check
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
name: Deploy docs
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['*.*.*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
SITE_SLUG: coursebank
|
|
||||||
CRATE: coursebank
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
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: Flatten so the crate sits at the channel root
|
|
||||||
run: bash scripts/flatten-docs.sh
|
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
name: Nightly
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
nightly:
|
|
||||||
runs-on: pixi-build-rust
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
env:
|
|
||||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
TAG: nightly
|
|
||||||
ASSET: coursebank-nightly-linux-x64.tar.gz
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install the pinned toolchain
|
|
||||||
run: pixi install
|
|
||||||
|
|
||||||
- name: Build the release binary
|
|
||||||
run: pixi run build
|
|
||||||
|
|
||||||
- name: Package
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
tar -czf "$ASSET" \
|
|
||||||
-C target/release coursebank \
|
|
||||||
-C "${{ gitea.workspace }}" LICENSE.md
|
|
||||||
sha256sum "$ASSET" > "$ASSET.sha256"
|
|
||||||
|
|
||||||
- name: Publish the rolling nightly prerelease
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
auth="Authorization: token ${TOKEN}"
|
|
||||||
# jq is fetched on demand; no toolchain change needed for it.
|
|
||||||
jq() { pixi exec --spec jq -- jq "$@"; }
|
|
||||||
|
|
||||||
sha="${{ gitea.sha }}"
|
|
||||||
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
body="Nightly build of ${version} at $(date -u +%Y-%m-%d) (commit ${sha:0:8}). Not a stable release."
|
|
||||||
|
|
||||||
# Drop the previous nightly release and its moving tag, if present, so
|
|
||||||
# the new one points at the current commit.
|
|
||||||
existing=$(curl -fsSL -H "$auth" "${API}/releases/tags/${TAG}" 2>/dev/null || true)
|
|
||||||
if [ -n "$existing" ]; then
|
|
||||||
id=$(printf '%s' "$existing" | jq -r '.id // empty')
|
|
||||||
[ -n "$id" ] && curl -fsSL -X DELETE -H "$auth" "${API}/releases/${id}"
|
|
||||||
fi
|
|
||||||
curl -fsSL -X DELETE -H "$auth" "${API}/tags/${TAG}" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Create the release at the current commit.
|
|
||||||
rid=$(curl -fsSL -X POST -H "$auth" -H "Content-Type: application/json" \
|
|
||||||
"${API}/releases" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg sha "$sha" --arg body "$body" \
|
|
||||||
'{tag_name:$tag, target_commitish:$sha, name:"Nightly", body:$body, draft:false, prerelease:true}')" \
|
|
||||||
| jq -r '.id')
|
|
||||||
|
|
||||||
# Attach the tarball and its checksum.
|
|
||||||
for f in "$ASSET" "$ASSET.sha256"; do
|
|
||||||
curl -fsSL -X POST -H "$auth" \
|
|
||||||
-F "attachment=@${f}" \
|
|
||||||
"${API}/releases/${rid}/assets?name=${f}"
|
|
||||||
done
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['*.*.*']
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 7 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
SITE_SLUG: coursebank
|
||||||
|
CRATE: coursebank
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: pixi-build-rust
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_DIR: /opt/cargo-target/${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install the pinned toolchain
|
||||||
|
run: pixi install
|
||||||
|
|
||||||
|
- 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' "<!doctype html><meta http-equiv=\"refresh\" content=\"0; url=${CRATE}/\">" > 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' }}
|
||||||
|
runs-on: pixi-build-rust
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- name: Install the pinned toolchain
|
||||||
|
run: pixi install
|
||||||
|
|
||||||
|
- name: Build and stage the bundle
|
||||||
|
run: |
|
||||||
|
COURSEBANK_VERSION="$(bash scripts/nightly-version.sh)"
|
||||||
|
export COURSEBANK_VERSION
|
||||||
|
echo "nightly version: ${COURSEBANK_VERSION}"
|
||||||
|
pixi run setup-tools
|
||||||
|
pixi run dist
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: bash scripts/package.sh coursebank-nightly-linux-x64.tar.gz dist
|
||||||
|
|
||||||
|
- name: Publish the rolling nightly prerelease
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
JQ: pixi exec --spec jq -- jq
|
||||||
|
TAG: nightly
|
||||||
|
PRERELEASE: "true"
|
||||||
|
REPLACE: "true"
|
||||||
|
TARGET_COMMITISH: ${{ gitea.sha }}
|
||||||
|
ASSETS: coursebank-nightly-linux-x64.tar.gz coursebank-nightly-linux-x64.tar.gz.sha256
|
||||||
|
run: |
|
||||||
|
ver="$(bash scripts/nightly-version.sh)"
|
||||||
|
export RELEASE_NAME="Nightly ${ver}"
|
||||||
|
export BODY="Nightly build ${ver}, built $(date -u +%Y-%m-%d). Not a stable release."
|
||||||
|
bash scripts/gitea-release.sh
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: check
|
||||||
|
if: ${{ gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/tags/') }}
|
||||||
|
runs-on: pixi-build-rust
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check the tag matches the crate version
|
||||||
|
env:
|
||||||
|
TAG: ${{ gitea.ref_name }}
|
||||||
|
run: bash scripts/check-tag-version.sh
|
||||||
|
|
||||||
|
- name: Install the pinned toolchain
|
||||||
|
run: pixi install
|
||||||
|
|
||||||
|
- name: Build the full bundle (binary and license notices)
|
||||||
|
run: |
|
||||||
|
pixi run setup-tools
|
||||||
|
pixi run dist
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: bash scripts/package.sh "coursebank-$(bash scripts/version.sh)-$(bash scripts/platform-tag.sh).tar.gz" dist
|
||||||
|
|
||||||
|
- name: Publish the release
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
JQ: pixi exec --spec jq -- jq
|
||||||
|
TAG: ${{ gitea.ref_name }}
|
||||||
|
BODY: See CHANGELOG.md.
|
||||||
|
run: |
|
||||||
|
asset="coursebank-$(bash scripts/version.sh)-$(bash scripts/platform-tag.sh).tar.gz"
|
||||||
|
export ASSETS="$asset $asset.sha256"
|
||||||
|
bash scripts/gitea-release.sh
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*.*.*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: pixi-build-rust
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
env:
|
|
||||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
TAG: ${{ gitea.ref_name }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Check the tag matches the crate version
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
tag="${TAG#v}"
|
|
||||||
if [ "$tag" != "$version" ]; then
|
|
||||||
echo "tag ${TAG} does not match Cargo.toml version ${version}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install the pinned toolchain
|
|
||||||
run: pixi install
|
|
||||||
|
|
||||||
- name: Build the full bundle (binary and license notices)
|
|
||||||
run: |
|
|
||||||
pixi run setup-tools
|
|
||||||
pixi run dist
|
|
||||||
|
|
||||||
- name: Package and publish the release
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
auth="Authorization: token ${TOKEN}"
|
|
||||||
jq() { pixi exec --spec jq -- jq "$@"; }
|
|
||||||
|
|
||||||
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
asset="coursebank-${version}-linux-x64.tar.gz"
|
|
||||||
tar -czf "$asset" -C dist coursebank LICENSE.md THIRD-PARTY-LICENSES.txt
|
|
||||||
sha256sum "$asset" > "$asset.sha256"
|
|
||||||
|
|
||||||
rid=$(curl -fsSL -X POST -H "$auth" -H "Content-Type: application/json" \
|
|
||||||
"${API}/releases" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" \
|
|
||||||
'{tag_name:$tag, name:$tag, body:"See CHANGELOG.md.", draft:false, prerelease:false}')" \
|
|
||||||
| jq -r '.id')
|
|
||||||
|
|
||||||
for f in "$asset" "$asset.sha256"; do
|
|
||||||
curl -fsSL -X POST -H "$auth" \
|
|
||||||
-F "attachment=@${f}" \
|
|
||||||
"${API}/releases/${rid}/assets?name=${f}"
|
|
||||||
done
|
|
||||||
@@ -12,8 +12,7 @@ jobs:
|
|||||||
sync:
|
sync:
|
||||||
runs-on: github-readme-sync
|
runs-on: github-readme-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the Gitea repo
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build the combined README
|
- name: Build the combined README
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ preview
|
|||||||
/dist/
|
/dist/
|
||||||
/THIRD-PARTY-LICENSES.txt
|
/THIRD-PARTY-LICENSES.txt
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
# pixi environments
|
# pixi environments
|
||||||
.pixi/*
|
.pixi/*
|
||||||
!.pixi/config.toml
|
!.pixi/config.toml
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coursebank"
|
name = "coursebank"
|
||||||
version = "26.8.0"
|
version = "0.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
|
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||||
|
// Copyright Scientific Computing Studio
|
||||||
|
// Source: https://git.scient.ing/education/coursebank
|
||||||
|
|
||||||
|
//! Chooses the version string compiled into the binary.
|
||||||
|
//!
|
||||||
|
//! Normal and release builds use the version from `Cargo.toml`. Nightly builds
|
||||||
|
//! set `COURSEBANK_VERSION` (to a git-derived string; see
|
||||||
|
//! `scripts/nightly-version.sh`) so `coursebank --version` reflects how far the
|
||||||
|
//! build is past the last release rather than an unreleased number.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let version = match env::var("COURSEBANK_VERSION") {
|
||||||
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
|
_ => env::var("CARGO_PKG_VERSION").expect("cargo always sets CARGO_PKG_VERSION"),
|
||||||
|
};
|
||||||
|
println!("cargo:rustc-env=COURSEBANK_VERSION={version}");
|
||||||
|
println!("cargo:rerun-if-env-changed=COURSEBANK_VERSION");
|
||||||
|
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
|
||||||
|
}
|
||||||
@@ -1,70 +1,101 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
name = "coursebank"
|
name = "coursebank"
|
||||||
version = "26.8.0"
|
version = "0.0.0"
|
||||||
authors = ["Scientific Computing Studio <opensource@scient.ing>"]
|
authors = ["Scientific Computing Studio <opensource@scient.ing>"]
|
||||||
channels = ["conda-forge"]
|
channels = ["conda-forge"]
|
||||||
platforms = ["linux-64", "osx-arm64", "osx-64"]
|
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]
|
[environments]
|
||||||
dev = ["dev"]
|
dev = ["dev"]
|
||||||
docs = ["docs"]
|
docs = ["docs"]
|
||||||
release = ["release"]
|
release = ["release"]
|
||||||
|
|
||||||
# Developer tooling: editor support, a debugger, a faster test runner, and a
|
[dependencies]
|
||||||
# file watcher.
|
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]
|
[feature.dev.dependencies]
|
||||||
rust-analyzer = "*"
|
rust-analyzer = "*"
|
||||||
lldb = "*"
|
lldb = "*"
|
||||||
cargo-nextest = "*"
|
cargo-nextest = "*"
|
||||||
|
|
||||||
[feature.dev.tasks]
|
[feature.dev.tasks.nextest]
|
||||||
nextest = { cmd = "cargo nextest run", description = "Run the test suite with 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]
|
[feature.docs.dependencies]
|
||||||
typst = "*"
|
typst = "*"
|
||||||
|
|
||||||
[feature.docs.tasks]
|
[feature.docs.tasks.typeset]
|
||||||
typeset = { cmd = "typst compile", description = "Compile a .typ file to PDF" }
|
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]
|
[feature.release.dependencies]
|
||||||
bump-my-version = "*"
|
bump-my-version = "*"
|
||||||
|
|
||||||
|
|||||||
Executable
+33
@@ -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/<owner>/<repo>
|
||||||
|
# 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
|
||||||
|
|
||||||
Executable
+18
@@ -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}"
|
||||||
|
|
||||||
Executable
+27
@@ -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/<owner>/<repo>}"
|
||||||
|
: "${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}"
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<div style="padding:.4rem .75rem;font:0.85rem/1.4 sans-serif;border-bottom:1px solid var(--border-color,#ddd);background:var(--sidebar-background-color,#f7f7f7);color:var(--main-color,#000)">
|
<div id="cb-channel-switch" style="position:fixed;bottom:0.75rem;right:0.75rem;z-index:1000;padding:.3rem .6rem;font:0.8rem/1.3 sans-serif;border:1px solid var(--border-color,#ccc);border-radius:6px;background:var(--main-background-color,#fff);color:var(--main-color,#000);box-shadow:0 1px 4px rgba(0,0,0,.2)">
|
||||||
Docs channel:
|
<a id="ch-release" href="#" style="margin:0 .25rem">release</a>|
|
||||||
<a id="ch-release" href="#" style="margin:0 .35rem">release</a>|
|
<a id="ch-nightly" href="#" style="margin:0 .25rem">nightly</a>
|
||||||
<a id="ch-nightly" href="#" style="margin:0 .35rem">nightly</a>
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var m = location.pathname.match(/^(.*\/)(release|nightly)\//);
|
var m = location.pathname.match(/^(.*\/)(release|nightly)\//);
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Flatten rustdoc output so the crate serves at the doc root rather than under
|
|
||||||
# <crate>/. Moves the crate's pages up to the doc root and drops one ../ from
|
|
||||||
# each page's references to the shared assets (CSS/JS, search index, source),
|
|
||||||
# scaled to the page's depth.
|
|
||||||
# Env: DOCROOT (default target/doc), CRATE (default coursebank).
|
|
||||||
set -euo pipefail
|
|
||||||
docroot="${DOCROOT:-target/doc}"
|
|
||||||
crate="${CRATE:-coursebank}"
|
|
||||||
[ -d "$docroot/$crate" ] || { echo "no $docroot/$crate; did you build the docs?" >&2; exit 1; }
|
|
||||||
cp -a "$docroot/$crate/." "$docroot/"
|
|
||||||
rm -rf "${docroot:?}/${crate:?}"
|
|
||||||
resources='static\.files/\|crates\.js\|search-index\|search\.desc/\|src/\|settings\.html\|help\.html\|trait\.impl/\|type\.impl/'
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
rel="${f#"$docroot"/}"
|
|
||||||
depth=$(printf '%s' "$rel" | tr -cd '/' | wc -c)
|
|
||||||
pat=''; for ((i=0; i<=depth; i++)); do pat="\\.\\./$pat"; done
|
|
||||||
rep=''; for ((i=0; i<depth; i++)); do rep="../$rep"; done
|
|
||||||
sed -i "s#${pat}\(${resources}\)#${rep}\1#g" "$f"
|
|
||||||
done < <(find "$docroot" -name '*.html' -print0)
|
|
||||||
echo "flattened $crate into $docroot"
|
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 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/<owner>/<repo>
|
||||||
|
# GITEA_TOKEN a token with contents:write
|
||||||
|
# TAG the release tag
|
||||||
|
# ASSETS space-separated list of files to attach
|
||||||
|
# Optional env:
|
||||||
|
# RELEASE_NAME (default: $TAG)
|
||||||
|
# BODY (default: empty)
|
||||||
|
# PRERELEASE true|false (default: false)
|
||||||
|
# REPLACE true|false (default: false) delete an existing release+tag first
|
||||||
|
# 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}"
|
||||||
|
body="${BODY:-}"
|
||||||
|
prerelease="${PRERELEASE:-false}"
|
||||||
|
replace="${REPLACE:-false}"
|
||||||
|
target="${TARGET_COMMITISH:-}"
|
||||||
|
auth="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
dry="${DRY_RUN:-}"
|
||||||
|
|
||||||
|
say() { echo "[gitea-release] $*"; }
|
||||||
|
|
||||||
|
if [ "$replace" = "true" ]; then
|
||||||
|
say "replacing any existing '$TAG' release and tag"
|
||||||
|
if [ -n "$dry" ]; then
|
||||||
|
say "DRY_RUN GET/DELETE ${GITEA_API}/releases/tags/${TAG} and DELETE tag ${TAG}"
|
||||||
|
else
|
||||||
|
existing=$(curl -fsSL -H "$auth" "${GITEA_API}/releases/tags/${TAG}" 2>/dev/null || true)
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
id=$(printf '%s' "$existing" | $JQ -r '.id // empty')
|
||||||
|
[ -n "$id" ] && curl -fsSL -X DELETE -H "$auth" "${GITEA_API}/releases/${id}"
|
||||||
|
fi
|
||||||
|
curl -fsSL -X DELETE -H "$auth" "${GITEA_API}/tags/${TAG}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$($JQ -n \
|
||||||
|
--arg tag "$TAG" --arg name "$name" --arg body "$body" \
|
||||||
|
--argjson pre "$prerelease" --arg target "$target" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}
|
||||||
|
+ (if $target == "" then {} else {target_commitish:$target} end)')
|
||||||
|
|
||||||
|
say "creating release '$TAG' (prerelease=$prerelease)"
|
||||||
|
if [ -n "$dry" ]; then
|
||||||
|
say "DRY_RUN POST ${GITEA_API}/releases"
|
||||||
|
printf '%s\n' "$payload"
|
||||||
|
rid="<release-id>"
|
||||||
|
else
|
||||||
|
rid=$(curl -fsSL -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||||
|
"${GITEA_API}/releases" -d "$payload" | $JQ -r '.id')
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in $ASSETS; do
|
||||||
|
n=$(basename "$f")
|
||||||
|
say "uploading $n"
|
||||||
|
if [ -n "$dry" ]; then
|
||||||
|
say "DRY_RUN POST ${GITEA_API}/releases/${rid}/assets?name=${n} (@${f})"
|
||||||
|
else
|
||||||
|
curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" \
|
||||||
|
"${GITEA_API}/releases/${rid}/assets?name=${n}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
say "done"
|
||||||
|
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Print an auto-generated nightly version string derived from git history:
|
||||||
|
#
|
||||||
|
# <last-release-tag>+<commits-since>.g<short-sha> e.g., 26.8.0+14.gb1a2c3d4
|
||||||
|
#
|
||||||
|
# When no release has been tagged yet, the base is 0.0.0 and the count is the
|
||||||
|
# total number of commits:
|
||||||
|
#
|
||||||
|
# 0.0.0+<total-commits>.g<short-sha> e.g., 0.0.0+57.gb1a2c3d4
|
||||||
|
#
|
||||||
|
# The `+...` part is SemVer build metadata, so the string stays a valid version
|
||||||
|
# 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 <tag>-<n>-g<hash>; the tag itself has no '-', so strip from the right.
|
||||||
|
rest="${desc%-g*}"
|
||||||
|
n="${rest##*-}"
|
||||||
|
tag="${rest%-*}"
|
||||||
|
echo "${tag}+${n}.g${sha}"
|
||||||
|
else
|
||||||
|
n="$(git rev-list --count HEAD)"
|
||||||
|
echo "0.0.0+${n}.g${sha}"
|
||||||
|
fi
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tar the contents of a directory into an asset and write its checksum.
|
||||||
|
# Usage: package.sh <asset.tar.gz> <dir>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
asset="${1:?usage: package.sh <asset.tar.gz> <dir>}"
|
||||||
|
dir="${2:?usage: package.sh <asset.tar.gz> <dir>}"
|
||||||
|
|
||||||
|
tar -czf "$asset" -C "$dir" .
|
||||||
|
sha256sum "$asset" > "$asset.sha256"
|
||||||
|
|
||||||
|
echo "packaged $asset ($(wc -c < "$asset") bytes) and $asset.sha256"
|
||||||
Executable
+20
@@ -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}"
|
||||||
|
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Point the site root at the latest release, falling back to nightly.
|
# Point the site root at the latest release, falling back to nightly.
|
||||||
# Env: WWW_ROOT (default /srv/www), SITE_SLUG (default coursebank).
|
# rustdoc serves the crate under <crate>/, so the target includes that segment.
|
||||||
|
# Env: WWW_ROOT (default /srv/www), SITE_SLUG (default coursebank),
|
||||||
|
# CRATE (default coursebank).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
root="${WWW_ROOT:-/srv/www}/${SITE_SLUG:-coursebank}"
|
root="${WWW_ROOT:-/srv/www}/${SITE_SLUG:-coursebank}"
|
||||||
if [ -e "$root/release/index.html" ]; then target="release/"; else target="nightly/"; fi
|
crate="${CRATE:-coursebank}"
|
||||||
|
|
||||||
|
if [ -e "$root/release/$crate/index.html" ]; then
|
||||||
|
target="release/$crate/"
|
||||||
|
else
|
||||||
|
target="nightly/$crate/"
|
||||||
|
fi
|
||||||
|
|
||||||
printf '%s\n' "<!doctype html><meta http-equiv=\"refresh\" content=\"0; url=${target}\">" > "$root/index.html"
|
printf '%s\n' "<!doctype html><meta http-equiv=\"refresh\" content=\"0; url=${target}\">" > "$root/index.html"
|
||||||
echo "site root -> $target"
|
echo "site root -> $target"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ use coursebank::store;
|
|||||||
|
|
||||||
/// Manage course item banks, assessments, and the analysis that comes back.
|
/// Manage course item banks, assessments, and the analysis that comes back.
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "coursebank", version, about, long_about = None)]
|
#[command(name = "coursebank", version = env!("COURSEBANK_VERSION"), about, long_about = None)]
|
||||||
pub(crate) struct Cli {
|
pub(crate) struct Cli {
|
||||||
/// Course directory, the one holding course.yaml.
|
/// Course directory, the one holding course.yaml.
|
||||||
#[arg(long, short = 'C', global = true, default_value = ".")]
|
#[arg(long, short = 'C', global = true, default_value = ".")]
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
//! ## The loop
|
//! ## The loop
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──┐
|
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ───┐
|
||||||
//! ▲ │
|
//! ▲ │
|
||||||
//! │ administer
|
//! │ administer
|
||||||
//! │ │
|
//! │ │
|
||||||
|
|||||||
Reference in New Issue
Block a user