The dry run caught this because someone read the output for COPY lines. I'd make missing lines fail the script on their own, so the next empty import can't get past a tired reviewer.
Two cheap assertions would have done it. Before running, count the commands in the generated file: grep -c '^\\copy' import.sql should equal the number of CSV files the export wrote, and the script stops if it doesn't. Inside the transaction, before the ROLLBACK, a DO block can compare each table's row count with a manifest from the export step and RAISE EXCEPTION on any mismatch. With \set ON_ERROR_STOP on, psql then exits 3 instead of 0.
That turns "nothing ran" into a red exit code. It's the same move you made later with checksums over counts: the check has to prove the work happened, not just that nothing failed.
The part that stands out is that the dry run's safety property depended on someone reading interspersed psql output for the presence of COPY lines, which is exactly the kind of check a tired reviewer skips on the tenth run, same failure class as the fix Mike's comment already covers. One thing I'd want to know: after switching echo for printf, did you re-run the dry run itself and confirm real COPY n lines and correct counts came back before trusting the real, irreversible import, or did the fix go straight to production on the strength of the diff plus the post-import checksums? The checksums are a solid final check, but they only run after the fact, when a wrong answer means restoring rather than rolling back — the dry run is the one designed to catch it before that point, so it seems worth confirming it still actually catches something after being the thing that silently didn't.