diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b220708
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+# Keep the docker build context to what the Dockerfile actually COPYs
+# (Cargo.toml, Cargo.lock, src). deploy.yml writes the DOT_ENV secret to .env in
+# the workspace before running `docker compose up --build`; without this file
+# that secret is tarred into the build context and handed to the docker daemon
+# on every deploy.
+.env
+.env.*
+target/
+.git
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..abea2e6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+# Format, lint, build and test on every pull request.
+#
+# This must run on a GitHub-hosted runner and never on the club VPS. The runner
+# deploy.yml uses lives on the club's own server and executes whatever a workflow
+# tells it to, as root, so nothing triggered by a pull request may be pointed at
+# it. Read that as a rule for this file, not a property of the repository: this
+# repo is public, forking is on, and fork pull requests only need approval from a
+# first-time contributor, so a fork that brings its own workflow can still reach
+# that runner. See SEC-01.
+#
+# SEC-01: point the protect-main ruleset's required status check at this job's
+# name ("fmt / clippy / build / test") so a red build blocks the merge that
+# deploys.
+
+name: CI
+
+on:
+ pull_request:
+
+# A newer push to the same pull request makes an in-flight run irrelevant.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ name: fmt / clippy / build / test
+ runs-on: ubuntu-latest
+ # A cold build of this dependency tree (serenity, reqwest, image, exr) in
+ # release, then clippy, then test, is slow on a 4-vCPU hosted runner. This is
+ # a guard against a hung job, not a target.
+ timeout-minutes: 45
+
+ steps:
+ # Third-party and first-party actions alike are pinned to a full commit
+ # SHA: a tag is mutable and can be repointed at new code by whoever owns
+ # the action.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ # Caches written by a pull_request run are scoped to that pull request, so
+ # this pays off across pushes to the same branch rather than across branches.
+ - name: Cache cargo registry and build artifacts
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-
+
+ # actions/runner-images documents rustfmt for ubuntu-24.04 but not clippy (it
+ # lists clippy only under ubuntu-22.04), while the image actually in use does
+ # ship it. Adding both components is a no-op when they are present and keeps
+ # this job working if that undocumented extra ever goes away. Print the
+ # versions too: clippy's lint set moves between releases, so knowing which one
+ # ran is what explains a lint that appeared from nowhere.
+ - name: Toolchain
+ run: |
+ rustup component add clippy rustfmt
+ cargo --version
+ cargo fmt --version
+ cargo clippy --version
+
+ - name: Format
+ run: cargo fmt --all -- --check
+
+ # Kept ahead of clippy and test: those resolve dependencies and would
+ # refresh Cargo.lock in place, so a stale lockfile would slip past
+ # --locked if they ran first.
+ - name: Build
+ run: cargo build --locked --release
+
+ - name: Clippy
+ run: cargo clippy --all-targets -- -D warnings
+
+ - name: Test
+ run: cargo test
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 7f4759b..ae6e01f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,14 +4,23 @@ on:
push:
branches:
- main
+ # Manual re-run, for redeploying after a rollback or re-creating the
+ # workspace .env that the cleanup step below now removes.
+ workflow_dispatch:
+
+permissions:
+ contents: read
jobs:
build_and_deploy:
runs-on: [self-hosted, linux]
+ # SEC-01: this gates nothing until required reviewers are configured on the
+ # "production" environment itself. See the pull request description.
+ environment: production
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
clean: true
fetch-depth: 1
@@ -35,5 +44,10 @@ jobs:
sudo docker compose up -d --build --force-recreate
echo "Deployment complete."
+ - name: Remove environment file
+ if: always()
+ working-directory: ${{ github.workspace }}
+ run: rm -f .env
+
- name: Clean up old Docker images
run: sudo docker image prune -f
diff --git a/Dockerfile b/Dockerfile
index 92180b1..8a5dff4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,14 +17,14 @@ RUN mkdir src && \
echo 'fn main() { println!("Dummy main for dependency caching"); }' > src/main.rs
# Build dependencies (this layer will be cached unless Cargo.toml/Cargo.lock changes)
-RUN cargo build --release && \
+RUN cargo build --locked --release && \
rm -rf src target/release/deps/dsec_bot*
# Copy the actual source code
COPY src ./src
# Build the actual application
-RUN cargo build --release
+RUN cargo build --locked --release
# Runtime stage - use a minimal image
FROM debian:bookworm-slim
diff --git a/src/commands/info.rs b/src/commands/info.rs
index 287c18a..bfec8f3 100644
--- a/src/commands/info.rs
+++ b/src/commands/info.rs
@@ -151,10 +151,10 @@ pub async fn serverinfo(ctx: Context<'_>) -> Result<(), Error> {
let server_description = server_description_option.as_deref().unwrap_or("N/A");
// rules channel, if empty N/A
- let rules_channel = if (&partial_guild.rules_channel_id).is_none() {
- "N/A"
+ let rules_channel = if let Some(rules_channel_id) = partial_guild.rules_channel_id {
+ &format!("<#{}>", rules_channel_id)
} else {
- &format!("<#{}>", &partial_guild.rules_channel_id.unwrap())
+ "N/A"
};
let embed_footer = CreateEmbedFooter::new(format!("ID: {}", server_id));
diff --git a/src/commands/member_info.rs b/src/commands/member_info.rs
index 57895c2..6d9ccc2 100644
--- a/src/commands/member_info.rs
+++ b/src/commands/member_info.rs
@@ -18,7 +18,7 @@ struct DiscordLinkRow {
student_id: String,
}
-async fn member_data(database: &Database, user_id: &String) -> Result