name: Upgrade Mutation Guard description: > Validates that added/modified upgrade commands stay in the current version directory and keep timestamps real and append-only, diffed against a given base. Shared by the PR-level guard (base = PR base) and the merge-queue guard (base = merge candidate's base, i.e. the current tip of main). inputs: base_sha: description: Commit to diff the working tree against. required: true allow_previous_version_mutation: description: Skip the version-directory check (PR label bypass). required: false default: 'false' runs: using: composite steps: - name: Fetch base commit shell: bash env: BASE_SHA: ${{ inputs.base_sha }} run: | set -euo pipefail if [ -z "$BASE_SHA" ]; then echo "::error::base_sha input is empty" exit 1 fi git fetch --no-tags --depth=1 origin "$BASE_SHA" - name: Check upgrade version commands are in current version only if: ${{ inputs.allow_previous_version_mutation != 'true' }} shell: bash env: BASE_SHA: ${{ inputs.base_sha }} COMMANDS_ROOT: packages/twenty-server/src/database/commands/upgrade-version-command run: | set -euo pipefail VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts" CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE") if [ -z "$CURRENT_VERSION" ]; then echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE" exit 1 fi CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/') echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)" DIFF=$(git diff --name-status --find-renames "$BASE_SHA" HEAD -- "$COMMANDS_ROOT") ADDED_FILES=$(echo "$DIFF" | awk '$1 == "A" { print $2 } $1 ~ /^[RC]/ { print $3 }') MODIFIED_FILES=$(echo "$DIFF" | awk '$1 == "M" { print $2 }') ADDED_OFFENDERS="" MODIFIED_OFFENDERS="" check_files() { local category="$1" shift for file in "$@"; do VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p') if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then if [ "$category" = "added" ]; then ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)" else MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)" fi fi done } check_files "added" $ADDED_FILES check_files "modified" $MODIFIED_FILES if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then echo "This change touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)." if [ -n "$ADDED_OFFENDERS" ]; then echo "" echo "New files added to non-current version directories:" echo -e "$ADDED_OFFENDERS" fi if [ -n "$MODIFIED_OFFENDERS" ]; then echo "" echo "Existing files modified in non-current version directories:" echo -e "$MODIFIED_OFFENDERS" fi echo "" echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to the PR and re-run CI." echo "Otherwise, move your changes to the current version directory ($CURRENT_DIR)." echo "::error::Upgrade commands were added or modified in non-current version directories." exit 1 fi - name: Check upgrade command timestamps are real and keep the sequence append-only shell: bash env: BASE_SHA: ${{ inputs.base_sha }} COMMANDS_ROOT: packages/twenty-server/src/database/commands/upgrade-version-command run: | set -euo pipefail extract_ts() { basename "$1" | sed -n -E 's/.*-(instance-command-(fast|slow)|workspace-command)-([0-9]{13})-.*/\3/p' } DIFF=$(git diff --name-status --find-renames "$BASE_SHA" HEAD -- "$COMMANDS_ROOT") NEW_FILES=$(echo "$DIFF" | awk '$1 == "A" { print $2 } $1 ~ /^[RC]/ { print $3 }') GONE_FILES=$(echo "$DIFF" | awk '$1 == "D" { print $2 } $1 ~ /^R/ { print $2 }') NOW_MS=$(($(date +%s) * 1000)) MIN_TS=$((NOW_MS - 60 * 24 * 3600 * 1000)) MAX_TS=$((NOW_MS + 2 * 24 * 3600 * 1000)) FAILED="" for file in $NEW_FILES; do case "$file" in *.spec.ts) continue ;; esac TS=$(extract_ts "$file") if [ -z "$TS" ]; then continue fi if [ "$TS" -lt "$MIN_TS" ] || [ "$TS" -gt "$MAX_TS" ]; then FAILED="$FAILED\n - $file (timestamp $TS is outside [now - 60 days, now + 2 days])" continue fi VERSION_DIR=$(dirname "$file") MAX_BASE_TS=0 for base_file in $(git ls-tree -r --name-only "$BASE_SHA" -- "$VERSION_DIR"); do case "$base_file" in *.spec.ts) continue ;; esac if echo "$GONE_FILES" | grep -qxF "$base_file"; then continue fi BASE_TS=$(extract_ts "$base_file") if [ -n "$BASE_TS" ] && [ "$BASE_TS" -gt "$MAX_BASE_TS" ]; then MAX_BASE_TS=$BASE_TS fi done if [ "$MAX_BASE_TS" -gt 0 ] && [ "$TS" -le "$MAX_BASE_TS" ]; then FAILED="$FAILED\n - $file (timestamp $TS <= existing max $MAX_BASE_TS)" fi done if [ -n "$FAILED" ]; then echo "Upgrade command timestamps must be the real epoch millis of when the command" echo "is authored (within 60 days before / 2 days after now), and strictly greater" echo "than every existing command in the same version directory." echo "" echo "The upgrade cursor tracks a single last-applied position: a command that sorts" echo "before already-applied commands moves the cursor backwards when it runs," echo "re-hiding columns gated by @WasIntroducedInUpgrade on instances that already" echo "applied them. Fabricated future timestamps poison the sequence for every" echo "command added after them." echo "" echo "If the blocking existing max is itself a fabricated future timestamp, re-slot" echo "that command to its real merge epoch (rename the file and the timestamp in the" echo "decorator/constant, and make its up() idempotent since it will re-run under" echo "the new name) instead of inflating the new command's timestamp." echo -e "$FAILED" echo "::error::Upgrade command timestamps must be real (close to now) and append-only within their version directory." exit 1 fi