Skip to main content

max / goingson

Pin migration checksums to catch edits to shipped migrations sqlx records each migration file's checksum when it runs and refuses to boot if a previously-applied file no longer hashes the same. The hash covers raw bytes, so comments count. 534a7f6 corrected a wrong comment in 047 and thereby crashed every install that had already applied it. Nothing caught this before release because the failure is invisible to fresh databases: with no recorded checksum there is nothing to contradict, so dev machines and CI stay green while existing installs abort at launch. That asymmetry is why this needs a test rather than review attention. Pin all 58 checksums to migrations/sqlite/CHECKSUMS so an in-place edit fails at cargo test, when it is still a one-line revert. Checksums come from sqlx's own Migrator, so they track its definition of the hash rather than a reimplementation that could drift. New migrations regenerate with UPDATE_MIGRATION_CHECKSUMS=1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-16 14:03 UTC
Commit: 793ec82f8b97ffbce14d59c00185138ab8ff1e51
Parent: 9a3bc67
2 files changed, +176 insertions, -0 deletions
@@ -0,0 +1,115 @@
1 + //! Applied migrations are immutable: once a migration ships, its bytes are frozen.
2 + //!
3 + //! `sqlx::migrate!` records each file's checksum in `_sqlx_migrations` when it runs
4 + //! and refuses to boot if a previously-applied file no longer hashes the same. The
5 + //! hash covers raw file bytes, so comments count. Editing a shipped migration
6 + //! therefore bricks every install that already ran it, while leaving fresh installs
7 + //! and CI perfectly green: they have no recorded checksum to contradict. That
8 + //! asymmetry is why this needs a test rather than review attention (see 534a7f6,
9 + //! a comment-only edit to 047 that crashed every existing install at launch).
10 + //!
11 + //! Checksums come from sqlx's own `Migrator`, so they track sqlx's definition of the
12 + //! hash rather than a reimplementation that could drift from it.
13 + //!
14 + //! Adding a migration: run the regenerate command below and commit the new line.
15 + //! Changing an existing line means changing a shipped migration, which is the thing
16 + //! this test exists to stop. Add a new migration instead.
17 + //!
18 + //! Regenerate: `UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite`
19 +
20 + use std::collections::BTreeMap;
21 + use std::fmt::Write as _;
22 + use std::path::PathBuf;
23 +
24 + /// Environment variable that rewrites the manifest instead of asserting against it.
25 + const UPDATE_VAR: &str = "UPDATE_MIGRATION_CHECKSUMS";
26 +
27 + fn manifest_path() -> PathBuf {
28 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite/CHECKSUMS")
29 + }
30 +
31 + /// Checksums of the migrations currently on disk, keyed by version.
32 + fn current_checksums() -> BTreeMap<i64, String> {
33 + sqlx::migrate!("../../migrations/sqlite")
34 + .iter()
35 + .map(|m| {
36 + let hex = m.checksum.iter().fold(String::new(), |mut acc, b| {
37 + let _ = write!(acc, "{b:02x}");
38 + acc
39 + });
40 + (m.version, hex)
41 + })
42 + .collect()
43 + }
44 +
45 + fn render(checksums: &BTreeMap<i64, String>) -> String {
46 + let mut out = String::from(
47 + "# sha384 of each migration file, as computed by sqlx.\n\
48 + # Regenerate: UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite\n\
49 + # A changed line means a shipped migration was edited. See migration_checksum_tests.rs.\n",
50 + );
51 + for (version, checksum) in checksums {
52 + let _ = writeln!(out, "{version:03} {checksum}");
53 + }
54 + out
55 + }
56 +
57 + fn parse(raw: &str) -> BTreeMap<i64, String> {
58 + raw.lines()
59 + .map(str::trim)
60 + .filter(|line| !line.is_empty() && !line.starts_with('#'))
61 + .map(|line| {
62 + let (version, checksum) = line
63 + .split_once(char::is_whitespace)
64 + .unwrap_or_else(|| panic!("malformed CHECKSUMS line: {line:?}"));
65 + let version = version
66 + .trim_start_matches('0')
67 + .parse()
68 + .unwrap_or_else(|e| panic!("bad version in {line:?}: {e}"));
69 + (version, checksum.trim().to_string())
70 + })
71 + .collect()
72 + }
73 +
74 + #[test]
75 + fn shipped_migrations_are_unmodified() {
76 + let current = current_checksums();
77 + let path = manifest_path();
78 +
79 + if std::env::var_os(UPDATE_VAR).is_some() {
80 + std::fs::write(&path, render(&current)).expect("write CHECKSUMS");
81 + return;
82 + }
83 +
84 + let raw = std::fs::read_to_string(&path).expect("read CHECKSUMS");
85 + let expected = parse(&raw);
86 +
87 + // Checked before the whole-map comparison: a modified migration is the case that
88 + // bricks installs, and it reads as an ordinary diff in the full assert output.
89 + let modified: Vec<i64> = current
90 + .iter()
91 + .filter(|(version, checksum)| {
92 + expected
93 + .get(*version)
94 + .is_some_and(|pinned| pinned != *checksum)
95 + })
96 + .map(|(version, _)| *version)
97 + .collect();
98 +
99 + assert!(
100 + modified.is_empty(),
101 + "migration(s) {modified:?} were edited after shipping.\n\
102 + Every install that already ran them will abort at launch with \
103 + \"migration N was previously applied but has been modified\".\n\
104 + Revert the edit and put the change in a new migration. If these \
105 + migrations have genuinely never shipped, regenerate with \
106 + {UPDATE_VAR}=1."
107 + );
108 +
109 + assert_eq!(
110 + expected, current,
111 + "migration set changed. New migrations are expected; regenerate with \
112 + {UPDATE_VAR}=1 and commit the result. A missing version means a \
113 + migration file was deleted, which breaks installs that ran it."
114 + );
115 + }
@@ -0,0 +1,61 @@
1 + # sha384 of each migration file, as computed by sqlx.
2 + # Regenerate: UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite
3 + # A changed line means a shipped migration was edited. See migration_checksum_tests.rs.
4 + 001 155650712b9cdccc0fb4183948354d079c6eee000ba107538785c99e1e5714cb8569d4c2c7a7265741dbdba448b1ba27
5 + 002 7874b8f9f6b1136514faf27e1ed99ac7137cc652dac48d23aa05b2001f41eb894dc39ba08fc6428a735029cd3774df64
6 + 003 ada2adcf02ef355b4682acb1af12005b05f46cf05fe19858cf993c4ea5c9aaa419ab714d8052090abfd06ab10cee4985
7 + 004 9d23e801b03ec062ba2faa015ac33aff76ba7f92c27ed123134023ce17424b8290e765941a47a8c49463a764d935e480
8 + 005 7b659e1cc294246bc3a6502f7bc8cdc76569ccceda0c016ccdf098160e83e9a13e4d952dcf0939de592923cf87cfc4bb
9 + 006 d4469d1d581a9fffc24d9e00294db59dae42ed04e07e7571dfaf209416023b33bdd23b3c2fde10032bb4b1a72841e1a3
10 + 007 32b00afa72f375f160739239650f1f48fbcfe960250a4ef07cd8496b4f3418a34e5af8b9593bca4ea09c7e398febc4f0
11 + 008 3b8e701b11f8310f8ae236e6cf3bc313235184d6a88f47bd29a02a54282daf23ea5285257b696082a84294011f8314ed
12 + 009 6803bff2ccc9c647a2f6b50db959dcc40483421699fc0f29e6b140760ca245b762e02dc99af962e9a78b1c96a700bb68
13 + 010 cfef4f1ef98c553feb16dab5e39487f272304d423f61d2a120b25022764386bf167a20d94f71cf580d134c8bcd354922
14 + 011 a759cbc373a8afab4300ebeea50f08996c0b237d60199e096420282656710447e1a9cb60668f3bb0bdd3df0bfc6367de
15 + 012 341f1e549a93a23756a53b12c86bfdae3e9ff0734030db1a21f09c45e5b93f5eb5848348edf9a8df18c6a204aee329a3
16 + 013 83b05386e7131302678908dc2220c547b654074aeedf926e3cbbc3d7abadba8110a47320f270eaa4385bf3c260ca8d00
17 + 014 7b1f3353da3b5a0ebeb86a916010e281501966c1ba5bf4bde54e337d9cc4e92973226a3ac38a7e8bef486922d220c164
18 + 015 eed7df01d1069359b149cf100740ac5dd1e3b169a275b754f0e4df4b20afcd22284fbd479e281a5ecbd0f00f083534f3
19 + 016 86e55e79eaeca4e8d129376c97a0d8dcb79a472bd949412e7d3e08e0a37b1bbc87f6b2baf3bed55dfaaaba4b64298c08
20 + 017 b6032357bf421e364a4c3f996996ebeba970df62f20a6aefac33430d83b0384e7d41dc6c46063c8deb31e973fb819216
21 + 018 1552e6242db630ff86c9f4de5c07d6dc02b6a13a6ec6953f054f66ed657e3ae98942b7da5cc9e03c6bb2c6e27258a592
22 + 019 480c810629434407afaadea3aff15c554c042284c7e0dc48d2c2d976b43e9ae05dd0c0d7809f50067d71a0b131605dce
23 + 020 d953f5ef8b15ba051cdcaa2db7614f6671c4a58d23b4b3a841db97146fcbbf91cf16523d2ed5c2d1537c5a1744e1a4d5
24 + 021 d31f2b1e6054c5e6d525a9f232d1102e09496bb3b790f776de5962313b0551970408beed50e6c81976478a8f26e85973
25 + 022 43eb77314c23a1e42f57ce30f55f7f37138d55b5da703b8df852c52310c4b3973891a4eb8508e91f728724d93d5d9718
26 + 023 fd41812ee035242242526e5e0ed324abb066042eeedb5717102f02938f9dbd3fb5a89b2941b161edf4533dd770f2c0c6
27 + 024 fb8849f0617e2dd085a5838fc1b809ef4c097ed6d601087d5862e5afca53e32db75cee01148776cbaca1dd96df4d4a9d
28 + 025 50e0ad0780c43d195a253df3d669e3c654930b74231b06efd7b0ef6465af2738e71475ff52b9454aa4625007993a8c18
29 + 026 9b1124463a4203cd6ec2a1d2c48cca473840a9530ec4bc95e89c7c8dfb420a1663cab3d7cf19be6c08f063a3e95d260d
30 + 027 058a1e4531dbcf625d6f489083767c43531250e436ca4d08e424b5f4df30ce319fe04da0765564ebee2a9c074c37fb52
31 + 028 87b6ec0d86a967874042f3c8b6682fe6fb5d094d105c69881cab7a2076634e9c9ecb1cebe8d205b527cf0c95d26b7282
32 + 029 20877077953ba45c99f95bad36e266a09c27a32fa927653a274dbd43103eb727f2b7d9251fab516df6abc7444650571f
33 + 030 a7d3ee7e8b96d689fc600c986636ffaf597eb027338bc1f81c73d85bae76a6ab4ff3cf46dd7d994fd367d8ff6b445b19
34 + 031 96715fa09e4a86ecbef4a44b859d278bf1a009b4d2eca35e0eaf94d552abb930293084a600977a40e184740933cc41b2
35 + 032 72dd6840dc11444884265170a9f71055d2b770235332130bbcd7b822e91afe194fdd886276dc88050367da13550ee3d2
36 + 033 5dab8c7617e11f10d80039e3f826fd465819a9a203e72716277a594c8cfb671acda40c0d294d3982d688490f82ac6142
37 + 034 6591c3d6176be95509089f4ef7538f117550c2a9b86ccdce8202e3573f26c73c2f0daa465d2550e905d99443e87ea8e8
38 + 035 b6762f76a3db4d32874a32f383104ddd9b3520dd007c5c1cf66c7536f9a724470a9d9f833765c2f1c7fadec5e950f626
39 + 036 f4fd75c448d57ab054fceecade58aa38c5f542e9bf7e9174c4c961fa698e197caf5fd30992f9052aaeda9a80a2e488f7
40 + 037 d8288bc9636683cded12fc9fb74ca4a171c28cc9e0333dd86e39780cf4aee943e59d7aa8189d8fc3165652105fd4066c
41 + 038 aa50928580dde80e506838493ea730a6d7d55c05a5f44b32040943b942d7c111d19c9b7cf5844f4c037042d7de8efe4f
42 + 039 e5346a9acabb12adefc38885346b05336c581683940368786ca55aba87b04b1b458ae7477943ff9d8d4a343ef2a7c4ab
43 + 040 a7cb2ea48609f6edcb112972a76902457bfceda1cc31deab7c9c183a4d039c74052400e71ad8a48c87aa74f9252c483e
44 + 041 d64e3e44ce5471feb504d23753264bfe1c197fa692a8e68632e7b02f37a2ac272520acf0c36f5118d921d42f24b4a974
45 + 042 9e4e842d8faebcfc32c269d292d53e9a521435b678a6359e5e4c288ef142e75f4fe83f703e8b1732b632ab62849764a8
46 + 043 eb869349dedab95c64f07ddb66141b4eb243bb7eff09d332eb50958c84c42669bc2185ce7a06682779ac8abc74a1fd30
47 + 044 60882a294baeffbffde7d4c7c4eda7fd0479f4d756e9c0d9ce036698741a88c35d8ca475ecfc187c0ce70290dd70ae4e
48 + 045 b1dc8d610896caf55003d9a344a11ead4d1ab2d47ca56fdc73e2f8277d283a02f67db366a88a55464a42b85380e4e6d5
49 + 046 f86d2804e22d12870c73e92baf7bcb708e569d606b56c3d04dbc918091498c270ecd710ec45b7956cb45f7ced60a6ef0
50 + 047 95bab50bf71496c84e6e1cf19c6189bce248d509ddae0d7ea254a8b28b952078934913c155473f37ae9d14542c89f1d1
51 + 048 cbd562e2f025a60da8e48f0fb2475ff42a66755987aa22e53c16359a252c80878ca36efb003df8df5b72bed0d2f4836d
52 + 049 10518f288aaa5b9649e842e900f473f3b51eef2c9fd56d9a93cea847f18f533e733eaa14f7aca8af8b50142531dd0793
53 + 050 699f89a338a0dd4c256e8417b5b27e496a61650cc323e4c0bc00e2678e5926bf0d212b4b621a606776063e4b233cc52e
54 + 051 a6908052a149965ff9bc15abca08f9a63c8ecd943e99659fad9101cc84718be9741a3f2d51fd3cc0431b1b46e361c0c3
55 + 052 4efab7a0abe69b0a6954769d77c3e7e7a45b407a02924a0bd0db793de969737c68dfaca51f7ca1bc43ff063ffb5ff35f
56 + 053 ad3740f87d409ac4906dec750a6f8c362b59ec3b344ba9d67bb4292ecdb3cf7b537df6e6a2e2f78f5961b75472f14ec3
57 + 054 8de1dfb9c3446712a39c4f45c573a579d2aeab680e30c66656ab521f4bb76eac16208a9a742cd791c12efb81d906e74c
58 + 055 38cabace85bccccd92dfd21e2cd5b766d0ea2bd397b874cb2a15ecb838662cfaec34069875f2fa7f7eda70ef6b8b6516
59 + 056 30e200908dd4f6d2ebdf5b8f1419207d9c9af430f99df471b61cfd3c9659851a0f8a9b9596aeb682c6567af76bd39ff6
60 + 057 f43f541166c9beb552e22bc5170fcb7bb95db580a6f82fc5dca88e74edeb433ff22e0bd72ddf3913800075a53d360978
61 + 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512