From 8b4f304b6a1c605f368465a7a91b76ecc2bdaab3 Mon Sep 17 00:00:00 2001 From: Sophie Poole <259036869+sophie-poole-nhs@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:45:48 +0100 Subject: [PATCH 1/2] VIA-1015 Updates to Dependabot - Changes dependabot to run every wednesday and to group minor and patch updates. - Adds a cI pipeline which runs when a PR is opened that changes the package-lock.json or package.json files - Removes npm audit in favour or dependabot's security alerts --- .github/dependabot.yaml | 45 ++-- .../cicd-13-dependency-cooldown-check.yaml | 29 +++ .../workflows/cicd-9-scheduled-assurance.yaml | 12 - scripts/reports/check-dependency-cooldown.sh | 215 ++++++++++++++++++ 4 files changed, 266 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/cicd-13-dependency-cooldown-check.yaml create mode 100755 scripts/reports/check-dependency-cooldown.sh diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 8bafbfe90..ddf4e3ef4 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,49 +1,48 @@ version: 2 updates: - # The target-branch is main (default branch) by default. + # Dependabot raises grouped PRs every Wednesday. + # Non-security patch/minor updates are merged on sprint Wednesdays (biweekly) + # and squash-merged into a single commit before promoting to the release branch. + # Security updates (critical/high) are handled immediately outside this cadence. + - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "wednesday" groups: - artifact: - patterns: [ "actions/*artifact" ] + all-actions: + patterns: ["*"] cooldown: default-days: 7 - package-ecosystem: "npm" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "wednesday" groups: - next: - patterns: [ "next", "next*", "*next", "*next*" ] - react: - patterns: [ "react", "react*", "*react", "*react*" ] - aws-sdk: - patterns: [ "@aws-sdk/*" ] - jest: - patterns: [ "jest", "jest*", "*jest", "*jest*" ] - testing-library: - patterns: [ "@testing-library/*" ] - eslint: - patterns: [ "eslint", "eslint*", "*eslint", "*eslint*" ] - prettier: - patterns: [ "prettier", "prettier*", "*prettier", "*prettier*" ] + all-non-major: + patterns: ["*"] + update-types: ["minor", "patch"] ignore: + - dependency-name: "next" + versions: [">=16.0.0"] + - dependency-name: "eslint-config-next" + versions: [">=16.0.0"] - dependency-name: "eslint" - versions: [ ">=10.0.0" ] + versions: [">=10.0.0"] cooldown: default-days: 7 - package-ecosystem: "terraform" - directories: [ "/infrastructure/modules/**" ] + directories: ["/infrastructure/modules/**"] groups: infrastructure-updates: - patterns: [ "*" ] + patterns: ["*"] ignore: - dependency-name: "*" - update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + update-types: ["version-update:semver-major", "version-update:semver-minor"] schedule: interval: "weekly" diff --git a/.github/workflows/cicd-13-dependency-cooldown-check.yaml b/.github/workflows/cicd-13-dependency-cooldown-check.yaml new file mode 100644 index 000000000..1a8b6bdc7 --- /dev/null +++ b/.github/workflows/cicd-13-dependency-cooldown-check.yaml @@ -0,0 +1,29 @@ +name: "CI/CD dependency cooldown check" + +on: + pull_request: + paths: + - 'package.json' + - 'package-lock.json' + +jobs: + cooldown-check: + name: "Dependency cooldown verification" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "Checkout code" + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: "Set up Node.js" + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + + - name: "Install dependencies" + run: npm ci + + - name: "Check dependency cooldown (changed packages only)" + run: ./scripts/reports/check-dependency-cooldown.sh 7 "origin/${{ github.base_ref }}" diff --git a/.github/workflows/cicd-9-scheduled-assurance.yaml b/.github/workflows/cicd-9-scheduled-assurance.yaml index 4d43f14e7..c4ec9bb0a 100644 --- a/.github/workflows/cicd-9-scheduled-assurance.yaml +++ b/.github/workflows/cicd-9-scheduled-assurance.yaml @@ -142,18 +142,6 @@ jobs: VITA_TEST_USER_PATTERN: ${{ secrets.VITA_TEST_USER_PATTERN }} AWS_S3_ARTEFACTS_BUCKET: vita-${{ secrets.AWS_ACCOUNT_ID }}-artefacts-preprod - - name: "Checkout ${{ env.R2_RELEASE_BRANCH }} for audit" - if: ${{ !cancelled() }} - uses: actions/checkout@v6 - with: - ref: ${{ env.R2_RELEASE_BRANCH }} - path: "release-audit" - - - name: "Audit npm packages (critical vulnerabilities)" - if: ${{ !cancelled() }} - working-directory: release-audit - run: npm audit --audit-level=critical - ################################################################# # Main branch deployment and assurance (e2e+snapshots+contract) diff --git a/scripts/reports/check-dependency-cooldown.sh b/scripts/reports/check-dependency-cooldown.sh new file mode 100755 index 000000000..b56be5774 --- /dev/null +++ b/scripts/reports/check-dependency-cooldown.sh @@ -0,0 +1,215 @@ +#!/bin/bash + +set -euo pipefail + +# Checks that changed npm dependencies (direct and transitive) meet the +# required cooldown period. By default, diffs package-lock.json against a base +# ref to only check packages whose versions changed — i.e. the ones Dependabot +# is proposing to update. Falls back to checking all dependencies when no base +# ref is available. +# +# Usage: +# $ [options] ./check-dependency-cooldown.sh [cooldown_days] [base_ref] +# +# Options: +# cooldown_days Number of days since publication required (default: 4) +# base_ref Git ref to diff against (default: origin/main) +# Set to "--all" to check every dependency +# VERBOSE=true Show all the executed commands, default is 'false' + +# ============================================================================== + +function main() { + + cd "$(git rev-parse --show-toplevel)" + + local cooldown_days="${1:-4}" + local base_ref="${2:-origin/main}" + + local now_epoch + now_epoch=$(date +%s) + local cooldown_seconds=$((cooldown_days * 86400)) + local threshold_epoch=$((now_epoch - cooldown_seconds)) + + local packages + packages=$(determine-packages "$cooldown_days" "$base_ref") + + if [[ -z "$packages" ]]; then + echo "No dependencies found. Run 'npm install' first." + exit 1 + fi + + check-cooldown "$packages" "$cooldown_days" "$now_epoch" "$threshold_epoch" +} + +# ============================================================================== + +function determine-packages() { + + local cooldown_days="$1" + local base_ref="$2" + + if [[ "$base_ref" == "--all" ]]; then + echo "Dependency Cooldown Check — ALL dependencies (minimum ${cooldown_days} days)" >&2 + echo "=========================================================================" >&2 + echo "" >&2 + get-all-packages + else + echo "Dependency Cooldown Check — CHANGED dependencies vs ${base_ref} (minimum ${cooldown_days} days)" >&2 + echo "=========================================================================" >&2 + echo "" >&2 + + if ! git rev-parse --verify "${base_ref}" >/dev/null 2>&1; then + echo "WARNING: Base ref '${base_ref}' not found. Falling back to checking all dependencies." >&2 + echo "" >&2 + get-all-packages + else + local changed + changed=$(get-changed-packages "$base_ref") + + if [[ -z "$changed" ]]; then + echo "No dependency version changes detected against ${base_ref}. Nothing to check." >&2 + exit 0 + fi + + echo "$changed" + fi + fi +} + +function get-all-packages() { + + local deps_json + deps_json=$(npm ls --all --json 2>/dev/null || echo '{}') + echo "$deps_json" | jq -r ' + [recurse(.dependencies // {} | to_entries[] | .value) | .dependencies // {} | to_entries[] | "\(.key)|\(.value.version // "unknown")"] + | unique[] + ' 2>/dev/null || echo "" +} + +function get-changed-packages() { + + local base_ref="$1" + + # Extract changed packages from the lockfile diff. + # Lockfile v3 keys look like "node_modules/pkg" or "node_modules/scope/pkg". + # We look for lines adding a new "version" under a node_modules entry. + git diff "${base_ref}" -- package-lock.json \ + | node -e ' + const fs = require("fs"); + const diff = fs.readFileSync(0, "utf8"); + const lockfile = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); + const pkgs = lockfile.packages || {}; + // Collect every node_modules path mentioned in added lines of the diff + const changedPaths = new Set(); + for (const line of diff.split("\n")) { + // Hunk headers in the diff reference keys like "node_modules/foo" + const keyMatch = line.match(/^[+]\s*"(node_modules\/.+?)":\s*\{/); + if (keyMatch) changedPaths.add(keyMatch[1]); + } + // Also detect version-line changes and map them back + let currentPath = null; + for (const line of diff.split("\n")) { + const pathMatch = line.match(/^[\s+"]*"?(node_modules\/.+?)"?\s*:\s*\{/); + if (pathMatch) currentPath = pathMatch[1]; + if (currentPath && /^\+.*"version"/.test(line)) changedPaths.add(currentPath); + } + const seen = new Set(); + for (const p of changedPaths) { + const entry = pkgs[p]; + if (!entry || !entry.version) continue; + // Derive package name from the path (handles scoped packages) + const name = p.replace(/^.*node_modules\//, ""); + const key = name + "|" + entry.version; + if (!seen.has(key)) { seen.add(key); console.log(key); } + } + ' 2>/dev/null || echo "" +} + +function check-cooldown() { + + local packages="$1" + local cooldown_days="$2" + local now_epoch="$3" + local threshold_epoch="$4" + local exit_code=0 + local pass_count=0 + local fail_count=0 + local skip_count=0 + + local count + count=$(echo "$packages" | wc -l | tr -d ' ') + echo "Checking ${count} package(s)..." + echo "" + + printf "%-45s %-15s %-12s %s\n" "Package" "Version" "Days ago" "Status" + printf "%-45s %-15s %-12s %s\n" "-------" "-------" "--------" "------" + + while IFS='|' read -r name version; do + [[ -z "$name" ]] && continue + + if [[ "$version" == "unknown" || -z "$version" ]]; then + printf "%-45s %-15s %-12s %s\n" "$name" "$version" "-" "SKIP" + ((skip_count += 1)) + continue + fi + + # Query npm registry for the publish date of this specific version + local time_json + time_json=$(npm view "${name}" time --json 2>/dev/null || echo "{}") + local publish_date + publish_date=$(echo "$time_json" | jq -r '."'"${version}"'" // empty' 2>/dev/null || echo "") + + if [[ -z "$publish_date" ]]; then + printf "%-45s %-15s %-12s %s\n" "$name" "$version" "-" "SKIP" + ((skip_count += 1)) + continue + fi + + # Use node for portable date parsing (works on both macOS and Linux) + local publish_epoch + publish_epoch=$(node -e "console.log(Math.floor(new Date(process.argv[1]).getTime()/1000))" "$publish_date") + + local days_ago=$(( (now_epoch - publish_epoch) / 86400 )) + + if [[ "$publish_epoch" -gt "$threshold_epoch" ]]; then + printf "%-45s %-15s %-12s %s\n" "$name" "$version" "${days_ago}d" "FAIL" + ((fail_count += 1)) + exit_code=1 + else + printf "%-45s %-15s %-12s %s\n" "$name" "$version" "${days_ago}d" "PASS" + ((pass_count += 1)) + fi + done <<< "$packages" + + echo "" + echo "Results: ${pass_count} passed, ${fail_count} failed, ${skip_count} skipped" + echo "Cooldown period: ${cooldown_days} days" + + if [[ "$exit_code" -ne 0 ]]; then + echo "" + echo "ERROR: Some dependencies do not meet the ${cooldown_days}-day cooldown period." + echo "These versions were published too recently and may not be stable." + fi + + exit "$exit_code" +} + +# ============================================================================== + +function is-arg-true() { + + if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then + return 0 + else + return 1 + fi +} + +# ============================================================================== + +is-arg-true "${VERBOSE:-false}" && set -x + +main "$@" + +exit 0 From 2d2f2f46ec347b58f55db2c8c5b23f01fc975424 Mon Sep 17 00:00:00 2001 From: Sophie Poole <259036869+sophie-poole-nhs@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:07:50 +0100 Subject: [PATCH 2/2] allow build