From d7af375cf1abb592acb654ed9efb50c50e616cc6 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 15:22:36 +0200 Subject: [PATCH 01/15] fix(commands/upgrade-postgres): resolve postgres volume from compose file Backup script assumed that the volume is dependend on the parent dir name. Could fail silently if directory is renamed. Derive volume name from compose file instead. --- commands/common.sh | 2 -- commands/upgrade-postgres.sh | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/commands/common.sh b/commands/common.sh index dd97fcf25..036b83cbb 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -85,12 +85,10 @@ init_environment() { export COMPOSE_FILE="compose.local.yml" export ENV_DIR="$PWD/.envs/.local" export COMPOSE_BASE_CMD="docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/django.env --env-file $ENV_DIR/postgres.env" - export POSTGRES_DATA_VOLUME="$(basename $PWD)_coda_local_postgres_data" else export COMPOSE_FILE="compose.production.yml" export ENV_DIR="$PWD/.envs/.production" export COMPOSE_BASE_CMD="docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/coda.env --env-file $ENV_DIR/postgres.env" - export POSTGRES_DATA_VOLUME="$(basename $PWD)_production_postgres_data" fi echo "Using environment: $CODA_ENV (compose file: $COMPOSE_FILE)" diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index a003395fc..c81d7fd35 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -43,6 +43,20 @@ if [ -z "$POSTGRES_VERSION" ]; then exit 1 fi +# Resolve the real compose-managed volume: key from the compose file itself, +# actual name from Docker's labels (immune to project/checkout renames). +VOLUME_KEYS="$($COMPOSE_BASE_CMD config --volumes | grep -E '_postgres_data$')" +if [ "$(printf '%s\n' "$VOLUME_KEYS" | wc -l)" -ne 1 ]; then + echo "Error: expected exactly one *_postgres_data volume in $COMPOSE_FILE, got:" >&2 + echo "$VOLUME_KEYS" >&2 + exit 1 +fi +POSTGRES_DATA_VOLUME="$(docker volume ls -q --filter "label=com.docker.compose.volume=${VOLUME_KEYS}" | head -1)" +if [ -z "$POSTGRES_DATA_VOLUME" ]; then + echo "Error: no compose-managed volume '${VOLUME_KEYS}' found. Aborting before any destructive step." >&2 + exit 1 +fi + echo "########################################################" echo "# Upgrading PostgreSQL Version" echo "# Environment: ${CODA_ENV}" From 7c8f38d4aafa81f66d7e5c17d5f2649ed8d6812a Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 15:26:29 +0200 Subject: [PATCH 02/15] fix(commands/backups): fix failing backup on non-interactive terminals Docker command on postgres was run with `-t` flag, which would fail when running in non-interactive terminals (e.g, cron). --- commands/backups.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/backups.sh b/commands/backups.sh index fe491e1d8..55e5eaceb 100755 --- a/commands/backups.sh +++ b/commands/backups.sh @@ -37,4 +37,4 @@ else exit 1 fi -$COMPOSE_BASE_CMD run --rm -it postgres $cmd +$COMPOSE_BASE_CMD run --rm postgres $cmd From 20404ac34abfc7700ea043a03389d57d701a3575 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 15:31:45 +0200 Subject: [PATCH 03/15] fix(commands): make --env require additional parameter. Passing --env without additional parameter would hand the script before. --- commands/common.sh | 5 +++++ commands/upgrade-postgres.sh | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/commands/common.sh b/commands/common.sh index 036b83cbb..d44b7a85c 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -32,6 +32,11 @@ parse_environment_args() { shift ;; --env) + if [ -z "${2:-}" ]; then + echo "Error: --env requires a value." >&2 + show_usage >&2 + exit 1 + fi temp_env="$2" shift 2 ;; diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index c81d7fd35..59df8ba05 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -12,6 +12,10 @@ filtered_args=() while [[ $# -gt 0 ]]; do case $1 in --postgres-version) + if [ -z "${2:-}" ]; then + echo "Error: --postgres-version requires a value." >&2 + exit 1 + fi postgres_version="$2" shift 2 ;; From aadc1343c9f7852fc99c6c132cfeeffa34acd578 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 15:40:37 +0200 Subject: [PATCH 04/15] fix(commands/common): convert to library. fix other scripts running without params. The tail block ran at source time with the *sourcing* script's positional params, guarded by `[ $# -gt 0 ]`. Invoking a script without an env flag skipped it entirely, leaving COMPOSE_BASE_CMD empty. So `$COMPOSE_BASE_CMD down` degraded to plain `down: command not found` (127) while the auto-detection meant to handle exactly that case was unreachable dead code. common.sh is now a define-only library; every consumer calls parse_environment_args + init_environment explicitly, and the documented `show_usage` behavior is reachable via a BASH_SOURCE standalone-CLI guard (sourcing stays side-effect-free). --- commands/common.sh | 5 +++-- commands/create-superuser.sh | 4 +++- commands/start-coda.sh | 4 +++- commands/stop-coda.sh | 4 +++- commands/update-coda.sh | 6 ++++-- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/commands/common.sh b/commands/common.sh index d44b7a85c..08cf9b2ae 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -99,8 +99,9 @@ init_environment() { echo "Using environment: $CODA_ENV (compose file: $COMPOSE_FILE)" } -# Main execution when sourced with arguments (for simple scripts) -if [ $# -gt 0 ]; then +# Standalone execution: resolve and report the environment. +# When sourced (BASH_SOURCE != $0) this file only defines functions. +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then parse_environment_args "$@" init_environment fi diff --git a/commands/create-superuser.sh b/commands/create-superuser.sh index 29661f4d7..72187ded6 100755 --- a/commands/create-superuser.sh +++ b/commands/create-superuser.sh @@ -1,6 +1,8 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" -source ${script_dir}/common.sh "$@" +source "${script_dir}/common.sh" +parse_environment_args "$@" +init_environment $COMPOSE_BASE_CMD exec django pdm run manage.py createsuperuser diff --git a/commands/start-coda.sh b/commands/start-coda.sh index bf27a9300..a22a7fd43 100755 --- a/commands/start-coda.sh +++ b/commands/start-coda.sh @@ -1,7 +1,9 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" -source ${script_dir}/common.sh "$@" +source "${script_dir}/common.sh" +parse_environment_args "$@" +init_environment _get_repo() { local remote diff --git a/commands/stop-coda.sh b/commands/stop-coda.sh index a33bd3cc1..5e63c38e6 100755 --- a/commands/stop-coda.sh +++ b/commands/stop-coda.sh @@ -1,7 +1,9 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" -source ${script_dir}/common.sh "$@" +source "${script_dir}/common.sh" +parse_environment_args "$@" +init_environment stop_coda() { $COMPOSE_BASE_CMD down diff --git a/commands/update-coda.sh b/commands/update-coda.sh index 28e3860d6..e60e09c48 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -83,8 +83,10 @@ parse_update_args() { # Parse arguments before sourcing common.sh parse_update_args "$@" -# Source common.sh for environment setup -source "${script_dir}/common.sh" "$@" +# Source common.sh for environment setup (pure library: define only) +source "${script_dir}/common.sh" +parse_environment_args "$@" +init_environment has_uncommitted_changes() { if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then From 6ccb4dc13a4bc15630de86c3290c1c5710f5a61e Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 15:49:44 +0200 Subject: [PATCH 05/15] fix(commands/upgrade-postgres): use $COMPOSE_BASE_CMD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script's four compose invocations hardwired `--env-file $ENV_DIR/coda.env --env-file $POSTGRES_ENV_FILE`, bypassing COMPOSE_BASE_CMD from common.sh. Two consequences: - On --local the contract is django.env + postgres.env; coda.env is the *production* env file. The upgrade path (build args, ${POSTGRES_VERSION} interpolation, compose project inputs) could therefore resolve differently than every other command — and did so quietly, agreeing with normal operation only because a coda.env also exists locally. - `pg_isready -U django` hardcoded the local username; production's POSTGRES_USER is different. It now reads ${POSTGRES_USER:?POSTGRES_USER missing in ...}, so both the correct user is used and a missing env entry fails with a named error. --- commands/upgrade-postgres.sh | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index 59df8ba05..003926b55 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -32,7 +32,7 @@ init_environment # Load postgres environment variables POSTGRES_ENV_FILE="$ENV_DIR/postgres.env" -source $POSTGRES_ENV_FILE +. "$POSTGRES_ENV_FILE" # Override POSTGRES_VERSION if provided via command line if [ -n "$postgres_version" ]; then @@ -75,30 +75,29 @@ echo "" $PWD/commands/backups.sh create --${CODA_ENV} echo "Shutting down CODA before PostgreSQL upgrade" -source ${script_dir}/stop-coda.sh -stop_coda +$COMPOSE_BASE_CMD down echo "Using pgautoupgrade image: pgautoupgrade/pgautoupgrade:${POSTGRES_VERSION}" -docker run --rm -e PGAUTO_ONESHOT=yes --env-file $POSTGRES_ENV_FILE -v ${POSTGRES_DATA_VOLUME}:/var/lib/postgresql/data pgautoupgrade/pgautoupgrade:${POSTGRES_VERSION} +docker run --rm -e PGAUTO_ONESHOT=yes --env-file "$POSTGRES_ENV_FILE" -v "${POSTGRES_DATA_VOLUME}":/var/lib/postgresql/data "pgautoupgrade/pgautoupgrade:${POSTGRES_VERSION}" echo "" echo "# PostgreSQL upgrade completed. Rebuilding container with new version..." echo "" # Rebuild postgres image with new version -docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/coda.env --env-file $POSTGRES_ENV_FILE build --build-arg POSTGRES_VERSION=${POSTGRES_VERSION} postgres +$COMPOSE_BASE_CMD build --build-arg POSTGRES_VERSION="${POSTGRES_VERSION}" postgres echo "" echo "# Starting PostgreSQL ${POSTGRES_VERSION} and checking collation version..." echo "" # Start with the new version -docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/coda.env --env-file $POSTGRES_ENV_FILE up -d postgres +$COMPOSE_BASE_CMD up -d postgres # Wait for postgres to be ready (with retries) echo "Waiting for PostgreSQL to be ready..." for i in {1..30}; do - if docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/coda.env --env-file $POSTGRES_ENV_FILE exec -T postgres pg_isready -U django > /dev/null 2>&1; then + if $COMPOSE_BASE_CMD exec -T postgres pg_isready -U "${POSTGRES_USER:?POSTGRES_USER missing in $POSTGRES_ENV_FILE}" > /dev/null 2>&1; then echo "PostgreSQL is ready!" break fi @@ -107,4 +106,4 @@ for i in {1..30}; do done # Run collation fix -docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/coda.env --env-file $POSTGRES_ENV_FILE run --rm postgres fix-collation +$COMPOSE_BASE_CMD run --rm postgres fix-collation From f67fdc5404d17efd3fff20d26e1ec8a3cdcc2c77 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 16:00:35 +0200 Subject: [PATCH 06/15] refactor(commands): unify import scripts into import.sh import_invoices.sh and import_requests.sh were 21-line clones differing only in the management command they invoked. One script now takes the manage.py command verbatim as its first argument: ./commands/import.sh import_fundingrequests --local requests.json ./commands/import.sh import_invoices --production invoices.json No alias table: a future import command works the day it lands, with no edit here and no second naming scheme to keep in sync and the script's argument is exactly what manage.py receives. A shape check (^[a-z][a-z0-9_]*$) rejects paths and flags before they reach the container. Unknown command names are reported by manage.py itself, which knows the real command list. Both old entry points are removed in the same commit and the user docs were updated to the real command names. The realpath file check applies to all imports now. It fails faster on bad path than invoking the whole django stack. --- commands/import.sh | 39 ++++++++++++++++++++++++++ commands/import_invoices.sh | 21 -------------- commands/import_requests.sh | 21 -------------- docs/users/features/fundingrequests.md | 2 +- docs/users/features/invoices.md | 4 +-- 5 files changed, 42 insertions(+), 45 deletions(-) create mode 100755 commands/import.sh delete mode 100755 commands/import_invoices.sh delete mode 100755 commands/import_requests.sh diff --git a/commands/import.sh b/commands/import.sh new file mode 100755 index 000000000..6fc935af3 --- /dev/null +++ b/commands/import.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Import a local data file through a Django management command. +# +# Usage: import.sh [--local|--production] + +script_dir="$(cd "$(dirname "$0")" && pwd)" + +source "${script_dir}/common.sh" +parse_environment_args "$@" +init_environment + +usage() { + echo "Usage: $0 [--local|--production] " >&2 +} + +if [ ${#remaining_args[@]} -ne 2 ]; then + usage + exit 1 +fi + +# The first argument is passed to manage.py verbatim (e.g. +# import_invoices, import_fundingrequests) — new import commands need no +# change here. The regex only rejects paths/flags that can't be commands. +MANAGE_CMD="${remaining_args[0]}" +if ! [[ "$MANAGE_CMD" =~ ^[a-z][a-z0-9_]*$ ]]; then + echo "Error: first argument must be a manage.py command name, e.g. import_fundingrequests." >&2 + usage + exit 1 +fi + +FILE_PATH="$(realpath "${remaining_args[1]}" 2>/dev/null)" || { + echo "Error: file not found: ${remaining_args[1]}" >&2 + exit 1 +} +MOUNT_DIR=$(dirname "$FILE_PATH") +FILE_NAME=$(basename "$FILE_PATH") + +$COMPOSE_BASE_CMD run --rm -v "$MOUNT_DIR:/imports" django pdm run manage.py "$MANAGE_CMD" "/imports/$FILE_NAME" diff --git a/commands/import_invoices.sh b/commands/import_invoices.sh deleted file mode 100755 index 98daa4c89..000000000 --- a/commands/import_invoices.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -script_dir="$(cd "$(dirname "$0")" && pwd)" - -# Parse arguments using common.sh function -source ${script_dir}/common.sh -parse_environment_args "$@" -init_environment - -# Check if file argument was provided -if [ ${#remaining_args[@]} -eq 0 ]; then - echo "Error: Please provide a file path to import." - echo "Usage: $0 [--local|--production] " - exit 1 -fi - -FILE_PATH="${remaining_args[0]}" -MOUNT_DIR=$(dirname "$FILE_PATH") -FILE_NAME=$(basename "$FILE_PATH") - -$COMPOSE_BASE_CMD run --rm -v "$MOUNT_DIR:/imports" django pdm run manage.py import_invoices "/imports/$FILE_NAME" diff --git a/commands/import_requests.sh b/commands/import_requests.sh deleted file mode 100755 index 8994365a6..000000000 --- a/commands/import_requests.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -script_dir="$(cd "$(dirname "$0")" && pwd)" - -# Parse arguments using common.sh function -source ${script_dir}/common.sh -parse_environment_args "$@" -init_environment - -# Check if file argument was provided -if [ ${#remaining_args[@]} -eq 0 ]; then - echo "Error: Please provide a file path to import." - echo "Usage: $0 [--local|--production] " - exit 1 -fi - -FILE_PATH="${remaining_args[0]}" -MOUNT_DIR=$(dirname "$FILE_PATH") -FILE_NAME=$(basename "$FILE_PATH") - -$COMPOSE_BASE_CMD run --rm -v "$MOUNT_DIR:/imports" django pdm run manage.py import_fundingrequests "/imports/$FILE_NAME" diff --git a/docs/users/features/fundingrequests.md b/docs/users/features/fundingrequests.md index e8b4c4d16..f6c00ffed 100644 --- a/docs/users/features/fundingrequests.md +++ b/docs/users/features/fundingrequests.md @@ -496,7 +496,7 @@ For system administrators, CODA provides a command-line import tool: ```bash # Using the shell script -./commands/import_requests.sh --local /path/to/requests.json +./commands/import.sh import_fundingrequests --local /path/to/requests.json # Or directly via Django management command pdm run manage.py import_fundingrequests /path/to/requests.json diff --git a/docs/users/features/invoices.md b/docs/users/features/invoices.md index 835437e95..4a9b1d667 100644 --- a/docs/users/features/invoices.md +++ b/docs/users/features/invoices.md @@ -470,10 +470,10 @@ For system administrators, CODA provides a command-line import tool: ```bash # Using the shell script -./commands/import_invoices.sh --production /path/to/invoices.json +./commands/import.sh import_invoices --production /path/to/invoices.json # Or for local environment -./commands/import_invoices.sh --local /path/to/invoices.json +./commands/import.sh import_invoices --local /path/to/invoices.json ``` This is useful for: From c91817ab87defeca7d2b8d370eedb8f2a7352301 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 16:10:42 +0200 Subject: [PATCH 07/15] fix(commands/update-coda): fail fast instead of continuing on failed git steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script had no global failure mode: step_fetch_and_switch ignored the result of `git fetch` and `git checkout -b`, and its `git checkout "$BRANCH" 2>/dev/null` classified *every* checkout error — network, conflict, unrelated breakage — as "branch missing locally". The concrete hazard, with CODA already stopped: fetch fails, the local branch does not exist yet, `checkout -b stable origin/stable` fails against the never-fetched ref, HEAD stays put — and the step still returned 0 (its status came from `echo ""`). Step 4 then ran `git pull origin stable` *into the wrong branch*, and step 5 started CODA from the merged result. - `set -euo pipefail` makes every unchecked command fatal. The previous `step_x || exit 1` call sites are replaced with plain calls, because `set -e` is deliberately disabled inside functions invoked in a conditional context — with `|| exit 1` kept, the option would have applied to nothing inside the steps. The now-dead `if [[ $? -ne 0 ]]` checks are gone; remaining checks are the ones that add a message. - The pull-failure rollback (restore previous branch, pop stash) moved from the middle of step 4 into the EXIT trap via ROLLBACK_ON_EXIT — under set -e the in-line recovery would be skipped on exactly the abort it was written for. The trap preserves the exit status. - Branch existence is decided by `git show-ref --verify` instead of trial-and-error checkout with stderr discarded. - `--branch` value read via `${2:-}` so set -u yields the script's own error message rather than an unbound-variable abort. Verified: unknown branch and missing --branch value exit 1 before any service is touched; --help exits 0; all steps pass bash -n. --- commands/update-coda.sh | 65 +++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/commands/update-coda.sh b/commands/update-coda.sh index e60e09c48..cfa3963f2 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -1,20 +1,28 @@ #!/bin/bash - +set -euo pipefail script_dir="$(cd "$(dirname "$0")" && pwd)" # Shared state between step functions STASH_REF="" +ROLLBACK_ON_EXIT=false CODA_STOPPED=false CODA_RESTARTED=false # Cleanup: restart CODA if it was stopped but not restarted cleanup() { + local status=$? + if [[ "$ROLLBACK_ON_EXIT" == true ]]; then + echo "Rolling back to the previous branch and restoring stashed changes..." >&2 + git checkout - 2>/dev/null || true + if [[ -n "$STASH_REF" ]]; then + git stash pop "$STASH_REF" 2>/dev/null || true + fi + fi if [[ "$CODA_STOPPED" == true && "$CODA_RESTARTED" == false ]]; then echo "Warning: CODA was stopped but the update failed. Restarting..." >&2 - ${script_dir}/start-coda.sh --${CODA_ENV} || true + "${script_dir}/start-coda.sh" --"$CODA_ENV" || true fi - - return 0 + return "$status" } trap cleanup EXIT @@ -50,7 +58,7 @@ parse_update_args() { local arg="$1" case "$arg" in --branch) - local branch_value="$2" + local branch_value="${2:-}" if [[ -z "$branch_value" || "$branch_value" == --* ]]; then echo "Error: --branch requires a value" >&2 exit 1 @@ -126,8 +134,7 @@ preflight_checks() { step_create_backup() { if [[ "$CREATE_BACKUP" == true ]]; then echo "Step 1/5: Creating backup..." - ${script_dir}/backups.sh --${CODA_ENV} create - if [[ $? -ne 0 ]]; then + if ! "${script_dir}/backups.sh" --"$CODA_ENV" create; then echo "Error: Backup failed. Aborting update." >&2 return 1 fi @@ -136,19 +143,16 @@ step_create_backup() { echo "Step 1/5: Skipping backup (use --backup to create one)" echo "" fi - return 0 } step_stop_coda() { echo "Step 2/5: Stopping CODA..." - ${script_dir}/stop-coda.sh --${CODA_ENV} - if [[ $? -ne 0 ]]; then + if ! "${script_dir}/stop-coda.sh" --"$CODA_ENV"; then echo "Error: Failed to stop CODA. Aborting update." >&2 return 1 fi CODA_STOPPED=true echo "" - return 0 } step_fetch_and_switch() { @@ -158,46 +162,43 @@ step_fetch_and_switch() { git stash push --include-untracked STASH_REF="stash@{0}" fi - git fetch origin "$BRANCH" - if ! git checkout "$BRANCH" 2>/dev/null; then + if ! git fetch origin "$BRANCH"; then + echo "Error: Fetching '$BRANCH' from origin failed. Aborting update." >&2 + return 1 + fi + if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + git checkout "$BRANCH" + else echo "Branch '$BRANCH' not found locally, creating it from remote..." git checkout -b "$BRANCH" "origin/$BRANCH" fi echo "" - return 0 } step_pull_and_restore() { echo "Step 4/5: Pulling latest changes and restoring stashed changes..." - git pull origin "$BRANCH" - if [[ $? -ne 0 ]]; then - echo "Error: Git pull failed. Restoring original branch..." >&2 - git checkout - 2>/dev/null || true - if [[ -n "${STASH_REF:-}" ]]; then - git stash pop "$STASH_REF" 2>/dev/null || true - fi + if ! git pull origin "$BRANCH"; then + echo "Error: Git pull failed. Original branch and local changes will be restored." >&2 + ROLLBACK_ON_EXIT=true return 1 fi - + # Restore stashed changes if we created one in this run - if [[ -n "${STASH_REF:-}" ]] && ! git stash pop "$STASH_REF" 2>&1; then + if [[ -n "$STASH_REF" ]] && ! git stash pop "$STASH_REF" 2>&1; then echo "Warning: Stash restore had conflicts." >&2 echo "Your local changes may need manual conflict resolution." >&2 fi echo "" - return 0 } step_start_coda() { echo "Step 5/5: Starting CODA (this will rebuild containers and run migrations)..." - ${script_dir}/start-coda.sh --${CODA_ENV} - if [[ $? -ne 0 ]]; then + if ! "${script_dir}/start-coda.sh" --"$CODA_ENV"; then echo "Error: Failed to start CODA." >&2 return 1 fi CODA_RESTARTED=true echo "" - return 0 } update_coda() { @@ -217,11 +218,11 @@ update_coda() { echo "Backup: $([[ "$CREATE_BACKUP" == true ]] && echo "Yes" || echo "No")" echo "" - step_create_backup || exit 1 - step_stop_coda || exit 1 - step_fetch_and_switch || exit 1 - step_pull_and_restore || exit 1 - step_start_coda || exit 1 + step_create_backup + step_stop_coda + step_fetch_and_switch + step_pull_and_restore + step_start_coda echo "Update completed successfully!" echo "" From 3492f452faab6367365c1c0281b6ffda78653033 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 16:16:05 +0200 Subject: [PATCH 08/15] feat(commands/update-coda): tagged git stash Give the git stash executed during upgrade a name instead of relying that it stays on top of the stack. --- commands/update-coda.sh | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/commands/update-coda.sh b/commands/update-coda.sh index cfa3963f2..de20dd921 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -4,10 +4,17 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" # Shared state between step functions STASH_REF="" +STASH_MSG="update-coda pre-update" ROLLBACK_ON_EXIT=false CODA_STOPPED=false CODA_RESTARTED=false +# True only while the slot STASH_REF still holds this run's tagged entry. +# Guards against another stash landing on top of ours between push and pop. +_stash_is_mine() { + [[ -n "$STASH_REF" ]] && [[ "$(git stash list -1 --format=%s "$STASH_REF" 2>/dev/null)" == *"$STASH_MSG"* ]] +} + # Cleanup: restart CODA if it was stopped but not restarted cleanup() { local status=$? @@ -15,7 +22,11 @@ cleanup() { echo "Rolling back to the previous branch and restoring stashed changes..." >&2 git checkout - 2>/dev/null || true if [[ -n "$STASH_REF" ]]; then - git stash pop "$STASH_REF" 2>/dev/null || true + if _stash_is_mine; then + git stash pop "$STASH_REF" 2>/dev/null || true + else + echo "Stash queue changed; your changes remain under '$STASH_MSG' in 'git stash list'." >&2 + fi fi fi if [[ "$CODA_STOPPED" == true && "$CODA_RESTARTED" == false ]]; then @@ -159,7 +170,7 @@ step_fetch_and_switch() { echo "Step 3/5: Fetching and switching to branch '$BRANCH'..." if has_uncommitted_changes; then echo "Stashing uncommitted changes..." - git stash push --include-untracked + git stash push --include-untracked -m "$STASH_MSG" STASH_REF="stash@{0}" fi if ! git fetch origin "$BRANCH"; then @@ -182,11 +193,18 @@ step_pull_and_restore() { ROLLBACK_ON_EXIT=true return 1 fi - + # Restore stashed changes if we created one in this run - if [[ -n "$STASH_REF" ]] && ! git stash pop "$STASH_REF" 2>&1; then - echo "Warning: Stash restore had conflicts." >&2 - echo "Your local changes may need manual conflict resolution." >&2 + if [[ -n "$STASH_REF" ]]; then + if _stash_is_mine; then + if ! git stash pop "$STASH_REF" 2>&1; then + echo "Warning: Stash restore had conflicts." >&2 + echo "Your local changes may need manual conflict resolution." >&2 + fi + else + echo "Warning: The stash queue changed during the update." >&2 + echo "Your changes are safe under '$STASH_MSG' — see 'git stash list'." >&2 + fi fi echo "" } From d2b086dbb34ae61912f2c99a0a968875771af62a Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 16:46:46 +0200 Subject: [PATCH 09/15] fix(commands/start-coda): fallback for repo remote The coda-oa/coda fallback in _get_repo can never executed. Made sure the mechanism also works for git+ssh, not only git+http. --- commands/start-coda.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/commands/start-coda.sh b/commands/start-coda.sh index a22a7fd43..a14919913 100755 --- a/commands/start-coda.sh +++ b/commands/start-coda.sh @@ -5,13 +5,23 @@ source "${script_dir}/common.sh" parse_environment_args "$@" init_environment +# echo owner/repo for GitHub URLs of any scheme (scp-style, ssh://, https://); +# nonzero for anything that doesn't parse, so _get_repo falls through. +_gh_repo() { + local r + r=$(printf '%s' "$1" | sed 's/.*github.com[:\/]//;s/\.git$//') + [[ "$r" == */* && "$r" != *:* ]] || return 1 + echo "$r" +} + _get_repo() { - local remote + local remote url remote=$(git rev-parse --abbrev-ref @{upstream} 2>/dev/null | cut -d/ -f1) if [[ -n "$remote" ]]; then - git remote get-url "$remote" 2>/dev/null | sed 's/.*github.com[:\/]//' | sed 's/\.git$//' && return + url=$(git remote get-url "$remote" 2>/dev/null) && _gh_repo "$url" && return fi - git remote get-url origin 2>/dev/null | sed 's/.*github.com[:\/]//' | sed 's/\.git$//' || echo "coda-oa/coda" + url=$(git remote get-url origin 2>/dev/null) && _gh_repo "$url" && return + echo "coda-oa/coda" } start_coda() { From b752fe4491e74049dffd6b7d0e0f549bcbf531d1 Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 17:33:00 +0200 Subject: [PATCH 10/15] chore(commands): make all scripts shellcheck -x clean - quote @{upstream}, $cmd, --$CODA_ENV expansions (SC1083/SC2086) - show_usage drops its unused script-name parameter (SC2119/SC2120) - source-path=SCRIPTDIR directives let shellcheck follow common.sh from any invocation directory; this also clears the remaining_args SC2154 warnings by making the assignment visible to the checker --- commands/backups.sh | 5 +++-- commands/common.sh | 2 +- commands/create-superuser.sh | 1 + commands/fix-collation.sh | 3 ++- commands/import.sh | 1 + commands/start-coda.sh | 3 ++- commands/stop-coda.sh | 1 + commands/update-coda.sh | 1 + commands/upgrade-postgres.sh | 6 ++++-- 9 files changed, 16 insertions(+), 7 deletions(-) diff --git a/commands/backups.sh b/commands/backups.sh index 55e5eaceb..f425a9248 100755 --- a/commands/backups.sh +++ b/commands/backups.sh @@ -3,7 +3,8 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" # Parse arguments using common.sh function -source ${script_dir}/common.sh +# shellcheck source-path=SCRIPTDIR +source "${script_dir}/common.sh" parse_environment_args "$@" init_environment @@ -37,4 +38,4 @@ else exit 1 fi -$COMPOSE_BASE_CMD run --rm postgres $cmd +$COMPOSE_BASE_CMD run --rm postgres "$cmd" diff --git a/commands/common.sh b/commands/common.sh index 08cf9b2ae..de6a56674 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -2,7 +2,7 @@ # Function to show usage information show_usage() { - local script_name="${1:-$0}" + local script_name="$0" echo "Usage: $script_name [OPTIONS] [COMMAND_ARGS...]" echo "" echo "Environment Options:" diff --git a/commands/create-superuser.sh b/commands/create-superuser.sh index 72187ded6..7911ceef6 100755 --- a/commands/create-superuser.sh +++ b/commands/create-superuser.sh @@ -1,6 +1,7 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR source "${script_dir}/common.sh" parse_environment_args "$@" init_environment diff --git a/commands/fix-collation.sh b/commands/fix-collation.sh index 06ccc37b8..76e2cf92a 100755 --- a/commands/fix-collation.sh +++ b/commands/fix-collation.sh @@ -3,7 +3,8 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" # Parse arguments using common.sh function -source ${script_dir}/common.sh +# shellcheck source-path=SCRIPTDIR +source "${script_dir}/common.sh" parse_environment_args "$@" init_environment diff --git a/commands/import.sh b/commands/import.sh index 6fc935af3..40be31cba 100755 --- a/commands/import.sh +++ b/commands/import.sh @@ -6,6 +6,7 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR source "${script_dir}/common.sh" parse_environment_args "$@" init_environment diff --git a/commands/start-coda.sh b/commands/start-coda.sh index a14919913..d2aa3e8be 100755 --- a/commands/start-coda.sh +++ b/commands/start-coda.sh @@ -1,6 +1,7 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR source "${script_dir}/common.sh" parse_environment_args "$@" init_environment @@ -16,7 +17,7 @@ _gh_repo() { _get_repo() { local remote url - remote=$(git rev-parse --abbrev-ref @{upstream} 2>/dev/null | cut -d/ -f1) + remote=$(git rev-parse --abbrev-ref "@{upstream}" 2>/dev/null | cut -d/ -f1) if [[ -n "$remote" ]]; then url=$(git remote get-url "$remote" 2>/dev/null) && _gh_repo "$url" && return fi diff --git a/commands/stop-coda.sh b/commands/stop-coda.sh index 5e63c38e6..593c54c44 100755 --- a/commands/stop-coda.sh +++ b/commands/stop-coda.sh @@ -1,6 +1,7 @@ #!/bin/bash script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source-path=SCRIPTDIR source "${script_dir}/common.sh" parse_environment_args "$@" init_environment diff --git a/commands/update-coda.sh b/commands/update-coda.sh index de20dd921..624a8dba7 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -103,6 +103,7 @@ parse_update_args() { parse_update_args "$@" # Source common.sh for environment setup (pure library: define only) +# shellcheck source-path=SCRIPTDIR source "${script_dir}/common.sh" parse_environment_args "$@" init_environment diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index 003926b55..1cb5d7e49 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -3,7 +3,8 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" # Parse arguments using common.sh function, but handle postgres-version specially -source ${script_dir}/common.sh +# shellcheck source-path=SCRIPTDIR +source "${script_dir}/common.sh" postgres_version="" filtered_args=() @@ -32,6 +33,7 @@ init_environment # Load postgres environment variables POSTGRES_ENV_FILE="$ENV_DIR/postgres.env" +# shellcheck source=/dev/null . "$POSTGRES_ENV_FILE" # Override POSTGRES_VERSION if provided via command line @@ -72,7 +74,7 @@ echo "" echo "# Creating backup of PostgreSQL data volume ${POSTGRES_DATA_VOLUME}..." echo "" -$PWD/commands/backups.sh create --${CODA_ENV} +"$PWD"/commands/backups.sh create --"$CODA_ENV" echo "Shutting down CODA before PostgreSQL upgrade" $COMPOSE_BASE_CMD down From b9dc276aa019147c3dbe7a41f3eb07aafe5b414c Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Fri, 4 Sep 2026 17:43:48 +0200 Subject: [PATCH 11/15] fix(commands): sonarcube cleanups --- commands/backups.sh | 4 ++-- commands/common.sh | 15 ++++++++------- commands/import.sh | 3 ++- commands/start-coda.sh | 9 +++++---- commands/upgrade-postgres.sh | 10 +++++----- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/commands/backups.sh b/commands/backups.sh index f425a9248..587c0371e 100755 --- a/commands/backups.sh +++ b/commands/backups.sh @@ -9,7 +9,7 @@ parse_environment_args "$@" init_environment # Parse backup command from remaining arguments -if [ ${#remaining_args[@]} -eq 0 ]; then +if [[ ${#remaining_args[@]} -eq 0 ]]; then echo "Error: Please provide a backup command." echo "Usage: $0 [--local|--production] [backup_name]" exit 1 @@ -22,7 +22,7 @@ if [[ $BACKUP_CMD = "create" ]]; then elif [[ $BACKUP_CMD = "list" ]]; then cmd="backups" elif [[ $BACKUP_CMD = "restore" ]]; then - if [ ${#remaining_args[@]} -lt 2 ]; then + if [[ ${#remaining_args[@]} -lt 2 ]]; then echo "Error: Please provide backup name for restore." echo "Usage: $0 [--local|--production] restore " exit 1 diff --git a/commands/common.sh b/commands/common.sh index de6a56674..7b17c7150 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -12,6 +12,7 @@ show_usage() { echo " --help, -h Show this help message" echo "" echo "If no environment is specified, auto-detection will be used based on available .envs directories." + return 0 } # Function to parse environment arguments and return remaining arguments @@ -32,7 +33,7 @@ parse_environment_args() { shift ;; --env) - if [ -z "${2:-}" ]; then + if [[ -z "${2:-}" ]]; then echo "Error: --env requires a value." >&2 show_usage >&2 exit 1 @@ -59,15 +60,15 @@ parse_environment_args() { # Function to initialize the environment after parsing init_environment() { # Skip if already initialized - if [ -n "$COMPOSE_BASE_CMD" ]; then + if [[ -n "$COMPOSE_BASE_CMD" ]]; then return 0 fi # Auto-detect environment if not specified - if [ -z "$CODA_ENV" ]; then - if [ -d "$PWD/.envs/.local" ]; then + if [[ -z "$CODA_ENV" ]]; then + if [[ -d "$PWD/.envs/.local" ]]; then CODA_ENV="local" - elif [ -d "$PWD/.envs/.production" ]; then + elif [[ -d "$PWD/.envs/.production" ]]; then CODA_ENV="production" else echo "Error: Cannot determine environment. Please specify --local or --production, or ensure environment files exist." @@ -78,7 +79,7 @@ init_environment() { fi # Validate environment - if [ "$CODA_ENV" != "local" ] && [ "$CODA_ENV" != "production" ]; then + if [[ "$CODA_ENV" != "local" && "$CODA_ENV" != "production" ]]; then echo "Error: Environment must be 'local' or 'production'. Current value: $CODA_ENV" echo "" show_usage @@ -86,7 +87,7 @@ init_environment() { fi # Set up compose command based on environment - if [ "$CODA_ENV" = "local" ]; then + if [[ "$CODA_ENV" == "local" ]]; then export COMPOSE_FILE="compose.local.yml" export ENV_DIR="$PWD/.envs/.local" export COMPOSE_BASE_CMD="docker compose -f $COMPOSE_FILE --env-file $ENV_DIR/django.env --env-file $ENV_DIR/postgres.env" diff --git a/commands/import.sh b/commands/import.sh index 40be31cba..19a7b672d 100755 --- a/commands/import.sh +++ b/commands/import.sh @@ -13,9 +13,10 @@ init_environment usage() { echo "Usage: $0 [--local|--production] " >&2 + return 0 } -if [ ${#remaining_args[@]} -ne 2 ]; then +if [[ ${#remaining_args[@]} -ne 2 ]]; then usage exit 1 fi diff --git a/commands/start-coda.sh b/commands/start-coda.sh index d2aa3e8be..f4ea5af47 100755 --- a/commands/start-coda.sh +++ b/commands/start-coda.sh @@ -9,10 +9,11 @@ init_environment # echo owner/repo for GitHub URLs of any scheme (scp-style, ssh://, https://); # nonzero for anything that doesn't parse, so _get_repo falls through. _gh_repo() { - local r - r=$(printf '%s' "$1" | sed 's/.*github.com[:\/]//;s/\.git$//') - [[ "$r" == */* && "$r" != *:* ]] || return 1 - echo "$r" + local remote_url="$1" + local repo_slug + repo_slug=$(printf '%s' "$remote_url" | sed 's/.*github.com[:\/]//;s/\.git$//') + [[ "$repo_slug" == */* && "$repo_slug" != *:* ]] || return 1 + echo "$repo_slug" } _get_repo() { diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index 1cb5d7e49..521e2369b 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -13,7 +13,7 @@ filtered_args=() while [[ $# -gt 0 ]]; do case $1 in --postgres-version) - if [ -z "${2:-}" ]; then + if [[ -z "${2:-}" ]]; then echo "Error: --postgres-version requires a value." >&2 exit 1 fi @@ -37,13 +37,13 @@ POSTGRES_ENV_FILE="$ENV_DIR/postgres.env" . "$POSTGRES_ENV_FILE" # Override POSTGRES_VERSION if provided via command line -if [ -n "$postgres_version" ]; then +if [[ -n "$postgres_version" ]]; then POSTGRES_VERSION="$postgres_version" echo "Overriding PostgreSQL version with command line value: $POSTGRES_VERSION" fi # Validate that POSTGRES_VERSION is set -if [ -z "$POSTGRES_VERSION" ]; then +if [[ -z "$POSTGRES_VERSION" ]]; then echo "Error: PostgreSQL version not specified. Please set POSTGRES_VERSION in $POSTGRES_ENV_FILE or use --postgres-version flag." echo "Usage: $0 [--local|--production] [--postgres-version VERSION]" exit 1 @@ -52,13 +52,13 @@ fi # Resolve the real compose-managed volume: key from the compose file itself, # actual name from Docker's labels (immune to project/checkout renames). VOLUME_KEYS="$($COMPOSE_BASE_CMD config --volumes | grep -E '_postgres_data$')" -if [ "$(printf '%s\n' "$VOLUME_KEYS" | wc -l)" -ne 1 ]; then +if [[ "$(printf '%s\n' "$VOLUME_KEYS" | wc -l)" -ne 1 ]]; then echo "Error: expected exactly one *_postgres_data volume in $COMPOSE_FILE, got:" >&2 echo "$VOLUME_KEYS" >&2 exit 1 fi POSTGRES_DATA_VOLUME="$(docker volume ls -q --filter "label=com.docker.compose.volume=${VOLUME_KEYS}" | head -1)" -if [ -z "$POSTGRES_DATA_VOLUME" ]; then +if [[ -z "$POSTGRES_DATA_VOLUME" ]]; then echo "Error: no compose-managed volume '${VOLUME_KEYS}' found. Aborting before any destructive step." >&2 exit 1 fi From e5cc8b9fe52e0b15d6d26a761ee46e76b7b91e3a Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Mon, 7 Sep 2026 10:51:50 +0200 Subject: [PATCH 12/15] fix(docs): import command usage --- docs/users/features/fundingrequests.md | 2 +- docs/users/features/invoices.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/users/features/fundingrequests.md b/docs/users/features/fundingrequests.md index f6c00ffed..34cc792bb 100644 --- a/docs/users/features/fundingrequests.md +++ b/docs/users/features/fundingrequests.md @@ -496,7 +496,7 @@ For system administrators, CODA provides a command-line import tool: ```bash # Using the shell script -./commands/import.sh import_fundingrequests --local /path/to/requests.json +./commands/import.sh --local import_fundingrequests /path/to/requests.json # Or directly via Django management command pdm run manage.py import_fundingrequests /path/to/requests.json diff --git a/docs/users/features/invoices.md b/docs/users/features/invoices.md index 4a9b1d667..a25ce5403 100644 --- a/docs/users/features/invoices.md +++ b/docs/users/features/invoices.md @@ -470,10 +470,10 @@ For system administrators, CODA provides a command-line import tool: ```bash # Using the shell script -./commands/import.sh import_invoices --production /path/to/invoices.json +./commands/import.sh --production import_invoices /path/to/invoices.json # Or for local environment -./commands/import.sh import_invoices --local /path/to/invoices.json +./commands/import.sh --local import_invoices /path/to/invoices.json ``` This is useful for: From a0d42976f7c91505277ef70035b29b52b841f32b Mon Sep 17 00:00:00 2001 From: Sergej Wildemann Date: Mon, 7 Sep 2026 10:52:26 +0200 Subject: [PATCH 13/15] fix(commands): hardening --- commands/backups.sh | 18 +++++++++++------- commands/common.sh | 3 +-- commands/import.sh | 8 ++++---- commands/update-coda.sh | 10 ++++++++-- commands/upgrade-postgres.sh | 13 ++++++++----- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/commands/backups.sh b/commands/backups.sh index 587c0371e..bcc83de72 100755 --- a/commands/backups.sh +++ b/commands/backups.sh @@ -17,11 +17,15 @@ fi BACKUP_CMD="${remaining_args[0]}" -if [[ $BACKUP_CMD = "create" ]]; then - cmd="backup" -elif [[ $BACKUP_CMD = "list" ]]; then - cmd="backups" -elif [[ $BACKUP_CMD = "restore" ]]; then +# Build the postgres command as an array so subcommand and backup name +# reach the container as separate argv entries (a quoted single string +# would arrive as one argument, which the postgres entrypoint can't exec). +POSTGRES_CMD=() +if [[ "$BACKUP_CMD" == "create" ]]; then + POSTGRES_CMD=(backup) +elif [[ "$BACKUP_CMD" == "list" ]]; then + POSTGRES_CMD=(backups) +elif [[ "$BACKUP_CMD" == "restore" ]]; then if [[ ${#remaining_args[@]} -lt 2 ]]; then echo "Error: Please provide backup name for restore." echo "Usage: $0 [--local|--production] restore " @@ -31,11 +35,11 @@ elif [[ $BACKUP_CMD = "restore" ]]; then $COMPOSE_BASE_CMD stop django echo "Ensuring postgres service is running for restore..." $COMPOSE_BASE_CMD up -d postgres - cmd="restore ${remaining_args[1]}" + POSTGRES_CMD=(restore "${remaining_args[1]}") else echo "Invalid command $BACKUP_CMD" echo "Usage: $0 [--local|--production] [backup_name]" exit 1 fi -$COMPOSE_BASE_CMD run --rm postgres "$cmd" +$COMPOSE_BASE_CMD run --rm postgres "${POSTGRES_CMD[@]}" diff --git a/commands/common.sh b/commands/common.sh index 7b17c7150..e96d62cdc 100755 --- a/commands/common.sh +++ b/commands/common.sh @@ -59,8 +59,7 @@ parse_environment_args() { # Function to initialize the environment after parsing init_environment() { - # Skip if already initialized - if [[ -n "$COMPOSE_BASE_CMD" ]]; then + if [[ -n "${COMPOSE_BASE_CMD:-}" ]]; then return 0 fi diff --git a/commands/import.sh b/commands/import.sh index 19a7b672d..2ccda8446 100755 --- a/commands/import.sh +++ b/commands/import.sh @@ -31,11 +31,11 @@ if ! [[ "$MANAGE_CMD" =~ ^[a-z][a-z0-9_]*$ ]]; then exit 1 fi -FILE_PATH="$(realpath "${remaining_args[1]}" 2>/dev/null)" || { +if [[ ! -f "${remaining_args[1]}" ]]; then echo "Error: file not found: ${remaining_args[1]}" >&2 exit 1 -} -MOUNT_DIR=$(dirname "$FILE_PATH") -FILE_NAME=$(basename "$FILE_PATH") +fi +MOUNT_DIR="$(cd "$(dirname "${remaining_args[1]}")" && pwd)" || exit 1 +FILE_NAME="$(basename "${remaining_args[1]}")" $COMPOSE_BASE_CMD run --rm -v "$MOUNT_DIR:/imports" django pdm run manage.py "$MANAGE_CMD" "/imports/$FILE_NAME" diff --git a/commands/update-coda.sh b/commands/update-coda.sh index 624a8dba7..bcaed8dde 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -12,7 +12,7 @@ CODA_RESTARTED=false # True only while the slot STASH_REF still holds this run's tagged entry. # Guards against another stash landing on top of ours between push and pop. _stash_is_mine() { - [[ -n "$STASH_REF" ]] && [[ "$(git stash list -1 --format=%s "$STASH_REF" 2>/dev/null)" == *"$STASH_MSG"* ]] + [[ -n "$STASH_REF" ]] && [[ "$(git log -1 --format=%s "$STASH_REF" 2>/dev/null)" == *"$STASH_MSG"* ]] } # Cleanup: restart CODA if it was stopped but not restarted @@ -169,6 +169,10 @@ step_stop_coda() { step_fetch_and_switch() { echo "Step 3/5: Fetching and switching to branch '$BRANCH'..." + # From here on the repo is in a half-updated state (stashed changes, about + # to switch branches): a failure at ANY later step must restore it, so the + # EXIT trap now rolls back branch and stash. Cleared only on success. + ROLLBACK_ON_EXIT=true if has_uncommitted_changes; then echo "Stashing uncommitted changes..." git stash push --include-untracked -m "$STASH_MSG" @@ -191,7 +195,6 @@ step_pull_and_restore() { echo "Step 4/5: Pulling latest changes and restoring stashed changes..." if ! git pull origin "$BRANCH"; then echo "Error: Git pull failed. Original branch and local changes will be restored." >&2 - ROLLBACK_ON_EXIT=true return 1 fi @@ -201,6 +204,8 @@ step_pull_and_restore() { if ! git stash pop "$STASH_REF" 2>&1; then echo "Warning: Stash restore had conflicts." >&2 echo "Your local changes may need manual conflict resolution." >&2 + else + STASH_REF="" fi else echo "Warning: The stash queue changed during the update." >&2 @@ -242,6 +247,7 @@ update_coda() { step_fetch_and_switch step_pull_and_restore step_start_coda + ROLLBACK_ON_EXIT=false echo "Update completed successfully!" echo "" diff --git a/commands/upgrade-postgres.sh b/commands/upgrade-postgres.sh index 521e2369b..523eb6045 100755 --- a/commands/upgrade-postgres.sh +++ b/commands/upgrade-postgres.sh @@ -51,12 +51,12 @@ fi # Resolve the real compose-managed volume: key from the compose file itself, # actual name from Docker's labels (immune to project/checkout renames). -VOLUME_KEYS="$($COMPOSE_BASE_CMD config --volumes | grep -E '_postgres_data$')" -if [[ "$(printf '%s\n' "$VOLUME_KEYS" | wc -l)" -ne 1 ]]; then - echo "Error: expected exactly one *_postgres_data volume in $COMPOSE_FILE, got:" >&2 - echo "$VOLUME_KEYS" >&2 +mapfile -t VOLUME_KEY_ARRAY < <($COMPOSE_BASE_CMD config --volumes | grep -E '_postgres_data$') +if [[ ${#VOLUME_KEY_ARRAY[@]} -ne 1 ]]; then + echo "Error: expected exactly one *_postgres_data volume in $COMPOSE_FILE, got ${#VOLUME_KEY_ARRAY[@]}." >&2 exit 1 fi +VOLUME_KEYS="${VOLUME_KEY_ARRAY[0]}" POSTGRES_DATA_VOLUME="$(docker volume ls -q --filter "label=com.docker.compose.volume=${VOLUME_KEYS}" | head -1)" if [[ -z "$POSTGRES_DATA_VOLUME" ]]; then echo "Error: no compose-managed volume '${VOLUME_KEYS}' found. Aborting before any destructive step." >&2 @@ -74,7 +74,10 @@ echo "" echo "# Creating backup of PostgreSQL data volume ${POSTGRES_DATA_VOLUME}..." echo "" -"$PWD"/commands/backups.sh create --"$CODA_ENV" +if ! "$PWD"/commands/backups.sh create --"$CODA_ENV"; then + echo "Error: Backup failed. Aborting before the destructive upgrade." >&2 + exit 1 +fi echo "Shutting down CODA before PostgreSQL upgrade" $COMPOSE_BASE_CMD down From 19ce8be22b9c00428a0deb0a8799a7a87c9ae0bf Mon Sep 17 00:00:00 2001 From: Sven Marcus Date: Tue, 8 Sep 2026 09:55:03 +0000 Subject: [PATCH 14/15] fix(commands/update-coda): restore original branch on rollback --- commands/update-coda.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/commands/update-coda.sh b/commands/update-coda.sh index bcaed8dde..35d9af1a5 100755 --- a/commands/update-coda.sh +++ b/commands/update-coda.sh @@ -6,6 +6,7 @@ script_dir="$(cd "$(dirname "$0")" && pwd)" STASH_REF="" STASH_MSG="update-coda pre-update" ROLLBACK_ON_EXIT=false +PREV_BRANCH="" CODA_STOPPED=false CODA_RESTARTED=false @@ -18,9 +19,17 @@ _stash_is_mine() { # Cleanup: restart CODA if it was stopped but not restarted cleanup() { local status=$? + local current_branch if [[ "$ROLLBACK_ON_EXIT" == true ]]; then - echo "Rolling back to the previous branch and restoring stashed changes..." >&2 - git checkout - 2>/dev/null || true + # Only restore the branch if we actually switched: a failure before the + # checkout (e.g. fetch) must not move the user off their current branch. + current_branch="$(git branch --show-current 2>/dev/null || true)" + if [[ -n "${PREV_BRANCH:-}" && "$current_branch" != "$PREV_BRANCH" ]]; then + echo "Rolling back to branch '$PREV_BRANCH' and restoring stashed changes..." >&2 + git checkout "$PREV_BRANCH" 2>/dev/null || true + else + echo "Restoring stashed changes..." >&2 + fi if [[ -n "$STASH_REF" ]]; then if _stash_is_mine; then git stash pop "$STASH_REF" 2>/dev/null || true @@ -173,6 +182,7 @@ step_fetch_and_switch() { # to switch branches): a failure at ANY later step must restore it, so the # EXIT trap now rolls back branch and stash. Cleared only on success. ROLLBACK_ON_EXIT=true + PREV_BRANCH="$(git branch --show-current 2>/dev/null || true)" if has_uncommitted_changes; then echo "Stashing uncommitted changes..." git stash push --include-untracked -m "$STASH_MSG" From 53823b305c104414b2888713ba3c0c57c7c7b9c4 Mon Sep 17 00:00:00 2001 From: Sven Marcus Date: Tue, 8 Sep 2026 14:36:49 +0000 Subject: [PATCH 15/15] docs(fundingrequests.md): demonstrate import command with production flag --- docs/users/features/fundingrequests.md | 52 +++++++++++++++----------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/users/features/fundingrequests.md b/docs/users/features/fundingrequests.md index 34cc792bb..3063565f7 100644 --- a/docs/users/features/fundingrequests.md +++ b/docs/users/features/fundingrequests.md @@ -1,6 +1,6 @@ # Funding Requests -Funding Requests are the core of CODA's workflow, allowing you to process publication funding and enabling your institution to review and approve those requests systematically. You can access the funding requests over the **Request Center** navigation item. +Funding Requests are the core of CODA's workflow, allowing you to process publication funding and enabling your institution to review and approve those requests systematically. You can access the funding requests over the **Request Center** navigation item. ## Overview @@ -73,14 +73,13 @@ Search and select a journal from CODA's database of over 26,000 entries: For monograph requests, you'll select a publisher instead of a journal. -You can also add a contract to the funding request. +You can also add a contract to the funding request. ![](/_static/img/fundingrequests_journal_step.png) - ### Step 2: Publication Details -In the second step author information, publication meta data and links (e.g. DOI, ISBN, PMC and more) can be added to the funding request. +In the second step author information, publication meta data and links (e.g. DOI, ISBN, PMC and more) can be added to the funding request. **Authors:** @@ -92,26 +91,27 @@ Collect information about the authors submitting the request: - **Affiliation**: Institutional affiliation - **Role**: The submitter's role in the publication (e.g., Corresponding Author or Submitter) -You can add one row per author and give the different roles. +You can add one row per author and give the different roles. ```{admonition} Tip -You can copy author information and paste it into the text area and let CODA parse the copied text to structured author data. This feature keeps author information that is not relevant for your process, for instance when many authors contributed, but only the corresponding author is interesting for you. +You can copy author information and paste it into the text area and let CODA parse the copied text to structured author data. This feature keeps author information that is not relevant for your process, for instance when many authors contributed, but only the corresponding author is interesting for you. ``` ![](/_static/img/fundingrequests_authors.png) - **Publication Metadata:** Record comprehensive metadata about the publication: **Basic Information:** + - **Title**: Publication title - **License**: Open access license (CC BY, CC BY-SA, etc.) - **Publication Type**: Default is based on [COAR Resource Types Vocabulary 3.1](https://vocabularies.coar-repositories.org/resource_types/3.1/); can be edited in the [vocabularies](vocabularies.md). - **Subject Area**: Default is based on [DFG Subject Classification](https://www.dfg.de/resource/blob/331950/85717c3edb9ea8bd453d5110849865d3/fachsystematik-2024-2028-en-data.pdf); can be edited in the [vocabularies](vocabularies.md). **Publication Status:** + - Unknown - Submitted - Accepted @@ -119,13 +119,12 @@ Record comprehensive metadata about the publication: - Published **Dates:** + - Online publication date - print publication date - **Additional References/Links:** -You ca add different kinds of identifiers and links related to the publication. For instance DOI, PMC, ISBN, Handle etc. The UI will validate most of the available link types to ensure correct data. - +You ca add different kinds of identifiers and links related to the publication. For instance DOI, PMC, ISBN, Handle etc. The UI will validate most of the available link types to ensure correct data. ```{admonition} Configurable Vocabularies The Publication Type and Subject Area fields are based on predefined vocabularies that can be configured by your institution in the [vocabularies](vocabularies.md) section. They are then set in the [Preferences](preferences.md). By default, CODA uses COAR Resource Types 3.1 and DFG Subject Classification. @@ -138,10 +137,11 @@ The Publication Type and Subject Area fields are based on predefined vocabularie In this step you can collect financial information: **Estimated Costs:** + - **Amount**: Estimated publication costs - **Currency**: Select the currency - **Payment Method**: Direct, Reimbursement, or Unknown -- **External cost splitting**: Tick this box to show that you shared costs with an external partner institute. This information is relevant for an [openCost report](reporting.md). +- **External cost splitting**: Tick this box to show that you shared costs with an external partner institute. This information is relevant for an [openCost report](reporting.md). **External Research Funding:** @@ -160,10 +160,9 @@ As the final costs of a publication are not always known when a funding request ![](/_static/img/fundingrequests_funding.png) - ### Step 4: Additional contact information -In this final step you can provide additional contact information regarding the funding request besides the authors information. You can also add notes and remarks. +In this final step you can provide additional contact information regarding the funding request besides the authors information. You can also add notes and remarks. ![](/_static/img/fundingrequests_contact.png) @@ -182,12 +181,11 @@ CODA runs automated checks against institutional policies to help guide your dec - **DOAJ Check**: Verifies if the journal is listed in the Directory of Open Access Journals - **Success** (green): Journal found in DOAJ with link to entry - **Failed** (red): Journal not listed in DOAJ - + - **Blocklist Check**: Checks if the journal or publisher is on your institution's [blocklist](blocklist.md) - **Success** (green): Journal and publisher are not blocked - **Warning** (yellow): Journal is blocked but needs review (6+ months old) - **Failed** (red): Journal or publisher is actively blocked - Check results provide immediate feedback on potential policy violations, but final approval decisions remain with reviewers. They are displayed in the detail's page right column. @@ -200,11 +198,13 @@ From the funding request detail page, click **Submit Review** to open the dedica It allows you to: **Set Decided Funding Amount:** + - Enter the approved funding amount - Select the currency - This can differ from the estimated cost if negotiation occurred **Add Reviewer Remarks:** + - Record notes about the review decision - Document special circumstances or exceptions @@ -230,6 +230,7 @@ Labels are custom tags you can attach to funding requests for: - Reporting and filtering (e.g., "Special Funds", "Research Initiative") Each label has: + - **Name**: Descriptive text - **Color**: You can select the color by using a color picker. @@ -254,7 +255,6 @@ From the funding request detail page: Labels are visible on both the detail page and the overview list. - ## Importing Funding Requests CODA supports bulk importing of funding requests from external systems using JSON files. This is useful for: @@ -351,7 +351,7 @@ Funding requests are imported using JSON files that follow a specific schema. Th } }, "estimated_cost": { - "amount": 2000.00, + "amount": 2000.0, "currency": "EUR", "payment_method": "direct" }, @@ -365,7 +365,7 @@ Funding requests are imported using JSON files that follow a specific schema. Th "review": { "result": "approved", "decided_funding": { - "amount": 1800.00, + "amount": 1800.0, "currency": "EUR" }, "remarks": "Approved with negotiated discount" @@ -385,12 +385,14 @@ Funding requests are imported using JSON files that follow a specific schema. Th The complete JSON schema is available for download [here](/_static/downloads/fundingrequest_import_schema.json). Key fields include: **Request Level:** + - `request_date` (required): Date in YYYY-MM-DD format - `legacy_request_id`: Identifier from your old system - `request_remarks`: Notes or comments about the request - `labels`: Array of label names (labels are auto-created if they don't exist) **Publication:** + - `title` (required): Publication title - `kind` (required): Either "article" or "monograph" - `license`: CC-BY, CC-BY-SA, CC-BY-NC, CC-BY-NC-SA, CC-BY-NC-ND, CC-BY-ND, CC0, Unknown, Proprietary, None @@ -406,16 +408,19 @@ The complete JSON schema is available for download [here](/_static/downloads/fun - `subject_area`: DFG classification or custom vocabulary **Estimated Cost:** + - `amount`: Numeric value (can be string or number) - `currency`: Three-letter currency code (EUR, USD, GBP, etc.) - `payment_method`: "direct", "reimbursement", or "unknown" **Research Funding:** + - `organization_name`: Funder name (auto-creates if doesn't exist) - `project_id`: Grant or project identifier - `project_name`: Full project name **Review:** + - `result`: "open", "approved", "rejected", "waived", or "closed" - `decided_funding`: Amount and currency - `remarks`: Reviewer notes @@ -427,7 +432,6 @@ The complete JSON schema is available for download [here](/_static/downloads/fun 3. Upload your JSON file 4. Click **Save** to start the import - ### Import Behavior **Auto-Creation of Related Entities:** @@ -444,6 +448,7 @@ CODA automatically creates missing related entities during import: **Review Status:** Imported requests can have any review status: + - Import historical **approved** or **rejected** requests with their decisions - Import **open** requests for ongoing review - Include decided funding amounts and reviewer remarks @@ -451,6 +456,7 @@ Imported requests can have any review status: **Validation:** The import validates all data against the schema: + - Required fields must be present - Dates must be in YYYY-MM-DD format - Currencies must be valid three-letter codes @@ -466,11 +472,13 @@ Imported requests bypass automated checks (DOAJ, Blocklist). This allows importi After import, you'll see: **Success Message:** + ``` Successfully imported 5 funding request(s). ``` **Partial Success with Errors:** + ``` Successfully imported 3 funding request(s). 2 request(s) failed to import. See details below. @@ -486,16 +494,19 @@ OLD-ID-456: Contract with name 'Unknown Contract' and year 2025 not found ``` Each error references either: + - The `legacy_request_id` if provided - The publication `title` if no legacy ID exists - ### Command-Line Import For system administrators, CODA provides a command-line import tool: ```bash # Using the shell script +./commands/import.sh --production import_fundingrequests /path/to/requests.json + +# Or for local environments ./commands/import.sh --local import_fundingrequests /path/to/requests.json # Or directly via Django management command @@ -505,4 +516,3 @@ pdm run manage.py import_fundingrequests /path/to/requests.json This is useful for large batch imports that might timeout in the browser The command-line import provides the same validation and error reporting as the web interface. -