diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Twentyseven
+
+## 1.0.0 - 2025-08-01
+
+- Update to GHC 9.12
+
+## 0.0.0 - 2016-03-16
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -7,25 +7,20 @@
 
 The main idea is to precompute, for every configuration, the number of moves
 required to put certain subsets of the 27 cubies composing the 3x3 Rubik's cube
-in their right place and/or in the right orientation. This gives lower bounds
+in their right place or in the right orientation. This gives lower bounds
 used for an A⋆-like search in the graph of scrambled cubes.
 
----
+Two algorithms are available in Twentyseven:
 
-By default, a suboptimal "two-phase" solver is used, as it runs rather quickly.
-It currently solves 1000 random cubes (uniformly distributed) in about one
-minute. The optimal solver is quite slow however, taking between five minutes
-and two hours to solve a random cube (18 moves in average).
+- the two-phase solver, which is suboptimal but fast: it solves 1000 random cubes (uniformly distributed) in one minute;
+- the optimal solver, which is quite slow, taking between five minutes and two hours to solve a random cube (18 moves in average).
 
-The solver must precompute a certain number of lookup tables, which can be
-stored in files. These tables take fifteen seconds to compute and weigh 13MB
-for the two-phase solver, compare that to about 8 hours and 2GB for the optimal
-one!
+The lookup tables for the two-phase solver take ten seconds to compute and weigh 13MB,
+compare that to about four hours and 2GB for the optimal solver!
+(Timed on a i7-10750H.)
 
-You may check the produced files with the checksums in `ts-tables.sha256`.
-A compressed archive `ts-tables.zip` (723MB) of all precomputed tables is
-available in the branch `fetch-tables` via `git-lfs`. Unzip it in `$HOME/.27/`,
-or wherever (see usage below).
+A compressed archive `ts-tables.tar.gz` (720MB) of all precomputed tables is
+available on Google Drive; see instructions at the bottom.
 
 Usage summary
 -------------
@@ -34,11 +29,14 @@
 
 - For the first invocation, use `-p` to precompute nonexistent lookup tables,
   otherwise an error is thrown when `twentyseven` tries to load them;
-- `--strict` loads tables immediately, otherwise they are loaded "by need" (so
-  you can also send it a cube to solve);
+- `--strict` loads tables immediately, otherwise they are loaded "by need";
 - `-d DIR` specifies the directory where the tables should be read and written
   (default: `$HOME/.27/`).
 
+For more options:
+
+    twentyseven --help
+
 The input is read line by line.
 
 Input format
@@ -52,7 +50,7 @@
 
   Output: a sequence of moves to unscramble it.
 
-  Facelets are numbered in base 9. Faces `0,1,2,3,4,5` correspond to `U,L,F,R,B,D`.
+  In the following figure, facelets are numbered in base 9. Faces `0,1,2,3,4,5` correspond to `U,L,F,R,B,D`.
 
                   00 01 02
                   03 04 05
@@ -93,7 +91,7 @@
 
 ### Initialization
 
-    $ echo quit|twentyseven -p --strict
+    $ twentyseven -p --strict < /dev/null
 
 ### Example
 
@@ -122,11 +120,10 @@
 
 ---
 
-Detail of current heuristics
-----------------------------
+Detail of heuristics
+--------------------
 
-The distance estimations are based on cosets corresponding to the following
-elements.
+The distance estimations are based on the following projections of the cube state.
 
 ### Two-phase
 
@@ -135,8 +132,8 @@
 - Corner Orientation × UD Slice
 - Edge Orientation × UD Slice
 
-It is possible to store the actual distances to the goal set in phase 1 but
-the current speed seems good enough for now.
+It is possible to store the actual distances to the goal set in phase 1 to
+make it even faster, but the two phase algorithm is fairly quick already.
 
 #### Phase 2
 
@@ -148,3 +145,28 @@
 - Corner Orientation × Edge Orientation
   × XY Slice Permutation, for XY in {UD, LR, FB}
 - Corner Orientation × Corner Permutation
+
+Download the tables
+-------------------
+
+If you can't wait four hours to precompute the optimal tables,
+you can download them from Google Drive.
+Pray to the Google gods that the URL still works.
+There are checksums for the archive as well as the uncompressed files in `ts-tables.sha256`.
+
+```sh
+# 1. This command prints a Google Drive URL (drive.usercontent.google.com) where you can download ts-tables.tar.gz
+sh print-ts-tables-url.sh twentyseven
+
+# 2. Download ts-tables.tar.gz
+
+# 3. The checksums should match
+sha256sum ts-tables.tar.gz
+tail -1 ts-tables.sha256
+
+# 4. Unpack tables into ~/.27
+mkdir ~/.27
+mv ts-tables.tar.gz ~/.27
+cd ~/.27
+tar zxf ts-tables.tar.gz
+```
diff --git a/exec-src/twentyseven.hs b/exec-src/twentyseven.hs
--- a/exec-src/twentyseven.hs
+++ b/exec-src/twentyseven.hs
@@ -12,12 +12,10 @@
 import Data.Time.Clock
 
 import Data.Char
-import Data.Monoid
 
 import Numeric ( showFFloat )
 
 import Options.Applicative hiding ( value )
-import qualified Options.Applicative as Opt
 
 import System.Exit
 import System.IO.Error
@@ -25,35 +23,35 @@
 type Solver = Cube -> Move
 
 data Parameters = Parameters {
-    verbose :: Bool,
     solve :: Solver,
-    tsPath :: Maybe FilePath,
     precompute :: Bool,
+    strict :: Bool,
     overwrite :: Bool,
     noFiles :: Bool,
-    strict :: Bool,
+    timed :: Bool,
+    tsPath :: Maybe FilePath,
     debug :: Bool
   }
 
 optparse :: Parser Parameters
 optparse = Parameters
-  <$> switch ( long "verbose" <> short 'v'
-        <> help "Print time taken to solve every cube" )
-  <*> flag TwoPhase.solve Optimal.solve ( long "optimal"
-        <> help "Use optimal solver (experimental)" )
-  <*> (optional . strOption) ( long "ts-dir" <> short 'd'
-        <> metavar "DIR"
-        <> help "Location of precomputed tables" )
+  <$> flag TwoPhase.solve Optimal.solve ( long "optimal"
+        <> help "Use optimal solver (slow!)" )
   <*> switch ( long "precompute" <> short 'p'
         <> help "Precompute and store tables \
                 \(do enable this at the first invocation)" )
+  <*> switch ( long "strict"
+        <> help "Force loading tables before doing anything else" )
   <*> switch ( long "overwrite"
         <> help "Recompute and overwrite tables even when they exist already" )
   <*> switch ( long "no-files"
         <> help "Do not read or write any files \
                 \(recompute tables for this session)" )
-  <*> switch ( long "strict"
-        <> help "Force loading tables before doing anything else" )
+  <*> switch ( long "timed" <> short 't'
+        <> help "Print time taken to solve every cube" )
+  <*> (optional . strOption) ( long "ts-dir" <> short 'd'
+        <> metavar "DIR"
+        <> help "Location of precomputed tables" )
   <*> switch ( long "debug" )
 
 main :: IO ()
@@ -84,13 +82,16 @@
   _ -> faceletList s p
 
 -- A sequence of moves, e.g., "URF".
+moveSequence :: String -> IO ()
 moveSequence s = putStrLn $
   case stringToMove s of
     Left c -> "Unexpected '" ++ [c] ++ "'."
     Right ms -> stringOfCubeColors . moveToCube . reduceMove $ ms
 
+faceletList :: [Char] -> Parameters -> IO ()
 faceletList = either (const . putStrLn) justSolve . readCube
 
+readCube :: (Eq b, Show b) => [b] -> Either String Cube
 readCube s
   = case colorFacelets'' s of
       Nothing -> Left "Expected string of length 54 of a set of (any) 6 \
@@ -111,21 +112,13 @@
 justSolve c p = do
   let solved = solve p c
       solStr = moveToString solved
-  flip vPutStrLn p . toString =<< clock (evaluate solved)
+  time <- clock (evaluate (solved `seq` ()))
+  when (timed p) (putStrLn (showFFloat (Just 2) time "s"))
   if c <> moveToCube solved == iden
   then putStrLn solStr
   else fail $ "Incorrect solver: " ++ solStr
-  where
-    toString d = showFFloat (Just 2) d "s"
 
-unlessQuiet' :: IO () -> Parameters -> IO ()
-unlessQuiet' a = unlessQuiet (const a) ()
-
--- Strict in its second argument
-unlessQuiet :: (a -> IO ()) -> a -> Parameters -> IO ()
-unlessQuiet f a p = evaluate a >> when (verbose p) (f a)
-
-clock :: IO a -> IO Double
+clock :: IO () -> IO Double
 clock a = do
   t <- getCurrentTime
   a
@@ -133,12 +126,3 @@
   return (diffTimeToSeconds (diffUTCTime t' t))
   where
     diffTimeToSeconds = fromRational . toRational
-
-listSeq' :: [a] -> [a]
-listSeq' s = s `listSeq` s
-
-vPutStrLn :: String -> Parameters -> IO ()
-vPutStrLn s = unlessQuiet putStrLn (listSeq' s)
-
-vPutStr :: String -> Parameters -> IO ()
-vPutStr s = unlessQuiet putStrLn (listSeq' s)
diff --git a/src/Data/Tuple/Template.hs b/src/Data/Tuple/Template.hs
--- a/src/Data/Tuple/Template.hs
+++ b/src/Data/Tuple/Template.hs
@@ -1,4 +1,4 @@
-{-# LANGUAGE TemplateHaskell, ViewPatterns #-}
+{-# LANGUAGE CPP, TemplateHaskell, ViewPatterns #-}
 module Data.Tuple.Template where
 
 import Control.Monad
@@ -31,7 +31,11 @@
     [typeD aas, consInlD, consD, splitInlD, splitD]
   where
     typeD aas@(a : as) =
+#if MIN_VERSION_template_haskell(2, 15, 0)
+      TySynInstD <$> tySynEqn Nothing (conT (mkName ":|") `appT` a `appT` tupleT as) (tupleT aas)
+#else
       TySynInstD (mkName ":|") <$> tySynEqn [a, tupleT as] (tupleT aas)
+#endif
     consInlD = pragInlD (mkName "|:|") Inline FunLike AllPhases
     splitInlD = pragInlD (mkName "split") Inline FunLike AllPhases
     consD = do
diff --git a/src/Data/Vector/HalfByte.hs b/src/Data/Vector/HalfByte.hs
--- a/src/Data/Vector/HalfByte.hs
+++ b/src/Data/Vector/HalfByte.hs
@@ -4,7 +4,7 @@
 
 {-# LANGUAGE FlexibleContexts, FlexibleInstances, GeneralizedNewtypeDeriving,
     MagicHash, MultiParamTypeClasses, RankNTypes, ScopedTypeVariables,
-    TypeFamilies #-}
+    TypeFamilies, UndecidableInstances #-}
 module Data.Vector.HalfByte where
 
 import Control.DeepSeq
diff --git a/src/Rubik/Cube/Cubie/Internal.hs b/src/Rubik/Cube/Cubie/Internal.hs
--- a/src/Rubik/Cube/Cubie/Internal.hs
+++ b/src/Rubik/Cube/Cubie/Internal.hs
@@ -11,7 +11,6 @@
 import Data.Function ( on )
 import Data.List
 import Data.Maybe
-import Data.Monoid
 import qualified Data.Vector.Unboxed as U
 import qualified Data.Vector.Unboxed.Mutable as MU
 
@@ -209,20 +208,28 @@
 
 --
 
+instance Semigroup CornerPermu where
+  CornerPermu b <> CornerPermu c = CornerPermu $ composeVector b c
+
 instance Monoid CornerPermu where
   mempty = CornerPermu $ idVector numCorners
-  mappend (CornerPermu b) (CornerPermu c) = CornerPermu $ composeVector b c
+  mappend = (<>)
 
 instance Group CornerPermu where
   inverse (CornerPermu a) = CornerPermu $ inverseVector a
 
+
+instance Semigroup EdgePermu where
+  EdgePermu b <> EdgePermu c = EdgePermu $ composeVector b c
+
 instance Monoid EdgePermu where
   mempty = EdgePermu $ idVector numEdges
-  mappend (EdgePermu b) (EdgePermu c) = EdgePermu $ composeVector b c
+  mappend = (<>)
 
 instance Group EdgePermu where
   inverse (EdgePermu a) = EdgePermu $ inverseVector a
 
+
 instance CubeAction CornerPermu where
   cubeAction cp_ = (cp_ <>) . fromCube
 
@@ -257,32 +264,36 @@
 
 --
 
-instance Monoid Corner where
-  mempty = Corner iden idCornerO
-    where idCornerO = CornerOrien $ U.replicate numCorners 0
-
-  mappend (Corner bp_ bo_)
+instance Semigroup Corner where
+  (<>)    (Corner bp_ bo_)
         c@(Corner cp_ co_)
     =      Corner dp_ do_
     where dp_ = bp_ <>             cp_
           do_ = bo_ `actionCorner` c
 
+instance Monoid Corner where
+  mempty = Corner iden idCornerO
+    where idCornerO = CornerOrien $ U.replicate numCorners 0
+  mappend = (<>)
+
 instance Group Corner where
   inverse (Corner ap_  (CornerOrien ao))
     =      Corner ap_' (CornerOrien ao')
     where ap_'@(CornerPermu ap') = inverse ap_
           ao'                    = U.map oInv . U.backpermute ao $ ap'
 
-instance Monoid Edge where
-  mempty = Edge iden idEdgeO
-    where idEdgeO = EdgeOrien $ U.replicate numEdges 0
-
-  mappend (Edge bp_ bo_)
+instance Semigroup Edge where
+  (<>)    (Edge bp_ bo_)
         c@(Edge cp_ co_)
     =      Edge dp_ do_
     where dp_ = bp_ <>           cp_
           do_ = bo_ `actionEdge` c
 
+instance Monoid Edge where
+  mempty = Edge iden idEdgeO
+    where idEdgeO = EdgeOrien $ U.replicate numEdges 0
+  mappend = (<>)
+
 instance Group Edge where
   inverse (Edge ap_  (EdgeOrien ao))
     =      Edge ap_' (EdgeOrien ao')
@@ -291,9 +302,12 @@
 
 --
 
+instance Semigroup Cube where
+  Cube cA eA <> Cube cB eB = Cube (cA <> cB) (eA <> eB)
+
 instance Monoid Cube where
   mempty = Cube iden iden
-  mappend (Cube cA eA) (Cube cB eB) = Cube (cA <> cB) (eA <> eB)
+  mappend = (<>)
 
 instance Group Cube where
   inverse (Cube c e) = Cube (inverse c) (inverse e)
diff --git a/src/Rubik/Cube/Facelet/Internal.hs b/src/Rubik/Cube/Facelet/Internal.hs
--- a/src/Rubik/Cube/Facelet/Internal.hs
+++ b/src/Rubik/Cube/Facelet/Internal.hs
@@ -20,9 +20,12 @@
     fromFacelets :: Vector Int
   } deriving (Eq, Show)
 
+instance Semigroup Facelets where
+  Facelets b <> Facelets c = Facelets $ composeVector b c
+
 instance Monoid Facelets where
   mempty = Facelets $ idVector numFacelets
-  mappend (Facelets b) (Facelets c) = Facelets $ composeVector b c
+  mappend = (<>)
 
 instance Group Facelets where
   inverse (Facelets a) = Facelets $ inverseVector a
diff --git a/src/Rubik/Cube/Moves/Internal.hs b/src/Rubik/Cube/Moves/Internal.hs
--- a/src/Rubik/Cube/Moves/Internal.hs
+++ b/src/Rubik/Cube/Moves/Internal.hs
@@ -16,7 +16,6 @@
 import Data.Function ( on )
 import Data.List
 import Data.Maybe
-import Data.Monoid
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as U
 
diff --git a/src/Rubik/IDA.hs b/src/Rubik/IDA.hs
--- a/src/Rubik/IDA.hs
+++ b/src/Rubik/IDA.hs
@@ -23,15 +23,19 @@
 type Result a l = Maybe [l]
 data SearchResult a l = Next !a | Found [l] | Stop
 
+instance Ord a => Semigroup (SearchResult a l) where
+  {-# INLINE (<>) #-}
+  (<>) f@(Found _) _ = f
+  (<>) _ f@(Found _) = f
+  (<>) (Next a) (Next b) = Next (min a b)
+  (<>) Stop x = x
+  (<>) x Stop = x
+
 instance Ord a => Monoid (SearchResult a l) where
   {-# INLINE mempty #-}
   mempty = Stop
   {-# INLINE mappend #-}
-  mappend f@(Found _) _ = f
-  mappend _ f@(Found _) = f
-  mappend (Next a) (Next b) = Next (min a b)
-  mappend Stop x = x
-  mappend x Stop = x
+  mappend = (<>)
 
 -- | Depth-first search up to depth @bound@,
 -- and reduce results from the leaves.
diff --git a/src/Rubik/Misc.hs b/src/Rubik/Misc.hs
--- a/src/Rubik/Misc.hs
+++ b/src/Rubik/Misc.hs
@@ -9,7 +9,6 @@
 import Control.Applicative
 
 import Data.Maybe
-import Data.Monoid
 import Data.Proxy (Proxy(..))
 import Data.List
 import qualified Data.Vector.Unboxed as U
diff --git a/src/Rubik/Solver/TwoPhase.hs b/src/Rubik/Solver/TwoPhase.hs
--- a/src/Rubik/Solver/TwoPhase.hs
+++ b/src/Rubik/Solver/TwoPhase.hs
@@ -10,7 +10,6 @@
 import Rubik.Tables.Distances
 
 import Data.Function ( on )
-import Data.Monoid
 
 {-# INLINE phase1Proj #-}
 phase1Proj
diff --git a/src/Rubik/Tables/Moves.hs b/src/Rubik/Tables/Moves.hs
--- a/src/Rubik/Tables/Moves.hs
+++ b/src/Rubik/Tables/Moves.hs
@@ -10,7 +10,6 @@
 import Data.Bifunctor
 import Data.Bits
 import Data.Maybe
-import Data.Monoid
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as U
 import qualified Data.Vector.Storable.Allocated as S
diff --git a/stack.yaml b/stack.yaml
deleted file mode 100644
--- a/stack.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-flags: {}
-packages:
-  - '.'
-extra-deps: []
-  # These deps are for tests and the first one needs fixing anyway.
-  #- HUnit-Plus-0.1.0
-  #- cabal-test-quickcheck-0.1.6
-resolver: lts-5.8
diff --git a/test/Test.hs b/test/Test.hs
--- a/test/Test.hs
+++ b/test/Test.hs
@@ -1,33 +1,50 @@
 {-# LANGUAGE LambdaCase, RecordWildCards, ScopedTypeVariables, ViewPatterns #-}
-module Test where
 
 import Rubik.Cube
 import Rubik.Cube.Facelet.Internal
 import Rubik.Cube.Cubie.Internal
-import Rubik.Cube.Moves.Internal
+import Rubik.Tables.Internal (setPrecompute)
 import Rubik.Tables.Moves
 import Rubik.Misc
 import Rubik.Symmetry
 
 import Control.Applicative
 import Control.Monad
-import Data.List
 import Data.List.Split (chunksOf)
 import Data.Maybe
-import Data.Monoid
+import Data.Proxy (Proxy(..))
+import Data.Tagged (Tagged(..))
 import qualified Data.Vector.Generic as G
-import qualified Data.Vector.Primitive.Pinned as P
-import Distribution.TestSuite
-import Distribution.TestSuite.QuickCheck
-import Test.HUnitPlus
-import Test.QuickCheck
+import qualified Data.Vector.Storable.Allocated as P
+import Test.Tasty (TestTree, askOption, defaultIngredients, defaultMainWithIngredients, includingOptions, testGroup)
+import Test.Tasty.Options (IsOption(..), OptionDescription(..), flagCLParser, safeReadBool)
+import Test.Tasty.Runners (TestTree(SingleTest, TestGroup))
+import Test.Tasty.HUnit
+import Test.Tasty.QuickCheck
 import qualified Test.QuickCheck as Gen
-import System.Environment
 
+type Test = TestTree
+
+main :: IO ()
+main = do
+  setPrecompute True
+  defaultMainWithIngredients ingredients tests
+  where
+    ingredients = includingOptions [Option (Proxy :: Proxy EnableOptimal)] : defaultIngredients
+
+newtype EnableOptimal = EnableOptimal Bool
+
+instance IsOption EnableOptimal where
+  defaultValue = EnableOptimal False
+  parseValue = (fmap . fmap) EnableOptimal safeReadBool
+  optionName = Tagged "enable-optimal"
+  optionHelp = Tagged "Run tests involving optimal solver"
+  optionCLParser = flagCLParser Nothing (EnableOptimal True)
+
 -- If the test suite receives some command line arguments, only tests whose
 -- fully qualified name has a prefix among them are run.
-tests :: IO [Test]
-tests = (filterTests . rename)
+tests :: Test
+tests = testGroup "twentyseven"
   [ testGroup "Cube"
     [ testGroup "Facelets"
       [ testProperty "permutation-to-facelet" $
@@ -149,7 +166,7 @@
           move18 move18CornerOrien
       , testMoveTables "move18EdgeOrien"
           move18 move18EdgeOrien
-      , testMoveTables "move18UDSlicePermu"
+      , requiresOptimal $ testMoveTables "move18UDSlicePermu"
           move18 move18UDSlicePermu
       , testMoveTables "move18UDSlice"
           move18 move18UDSlice
@@ -160,13 +177,26 @@
       ]
     , testUDSlicePermu
     , testFlipUDSlicePermu
-    , testRawToSymFlipUDSlicePermu
-    , testSymReprTable "srFUDSP"
+    , requiresOptimal testRawToSymFlipUDSlicePermu
+    , requiresOptimal $ testSymReprTable "srFUDSP"
         reprFlipUDSlicePermu conjugateFlipUDSlicePermu
-    , testMoveSymTables "msFUDSP" move18 move18SymFlipUDSlicePermu
+    , requiresOptimal $ testMoveSymTables "msFUDSP" move18SymFlipUDSlicePermu
     ]
   ]
 
+-- Argument must be SingleTest or TestGroup
+requiresOptimal :: TestTree -> TestTree
+requiresOptimal t =
+  askOption $ \(EnableOptimal enable) ->
+      if enable then
+        t
+      else
+        let name = case t of
+              SingleTest name _ -> name
+              TestGroup name _ -> name
+              _ -> error "expected named test" in
+        testCaseInfo name (pure "Skipped (requires --enable-optimal)")
+
 -- * Facelets
 
 genFacelets = unsafeFacelets' <$> shuffle [0 .. 53]
@@ -247,11 +277,11 @@
 -- * Moves
 
 testMoves :: String -> String -> Test
-testMoves moves result = '.' : moves ~:
-  (stringOfCubeColors . moveToCube <$> stringToMove moves) ~?= Right result
+testMoves moves result = testCase ('_' : moves) $
+  (stringOfCubeColors . moveToCube <$> stringToMove moves) @?= Right result
 
 testCube :: String -> Cube -> String -> Test
-testCube name c result = name ~: stringOfCubeColors c ~?= result
+testCube name c result = testCase name $ stringOfCubeColors c @?= result
 
 -- * Move tables
 
@@ -283,13 +313,13 @@
     genCoordFUDSP = RawCoord <$> Gen.choose (0, range ([] :: [FlipUDSlicePermu]) -1)
 
 testMoveSymTables :: ()
-  => String -> MoveTag m [Cube] -> MoveTag m [SymMove UDFix FlipUDSlicePermu]
+  => String -> MoveTag m [SymMove UDFix FlipUDSlicePermu]
   -> Test
-testMoveSymTables name (MoveTag cubes) (MoveTag moves)
+testMoveSymTables name (MoveTag moves)
   = testProperty name $
-      conjoin $ zipWith propMoveSymTable1 cubes moves
+      conjoin $ propMoveSymTable1 <$> moves
 
-propMoveSymTable1 c (SymMove m)
+propMoveSymTable1 (SymMove m)
   -- = forAll (Gen.choose (0, P.length m-1)) $ \x ->
   = case G.find (\x -> x >= 16 * P.length m) m of
       Nothing -> property True
@@ -306,9 +336,8 @@
 -- * Typeclass laws
 
 testMonoid0 :: (Monoid a, Eq a, Show a) => proxy a -> Test
-testMonoid0 proxy =
-  "mempty-mappend-mempty" ~:
-    mempty <> mempty ~?= mempty `asProxyTypeOf` proxy
+testMonoid0 proxy = testCase "mempty-mappend-mempty" $
+    mempty <> mempty @?= mempty `asProxyTypeOf` proxy
 
 testMonoid :: (Monoid a, Eq a, Show a) => Gen a -> Test
 testMonoid gen = testGroup "Monoid"
@@ -323,9 +352,8 @@
   ]
 
 testGroup0 :: (Group a, Eq a, Show a) => proxy a -> Test
-testGroup0 proxy =
-  "inverse-mempty" ~:
-    inverse mempty ~?= mempty `asProxyTypeOf` proxy
+testGroup0 proxy = testCase "inverse-mempty" $
+    inverse mempty @?= mempty `asProxyTypeOf` proxy
 
 testGroupInstance :: (Group a, Eq a, Show a) => Gen a -> Test
 testGroupInstance gen = testGroup "Group"
@@ -340,7 +368,7 @@
 testMonoidMorphism :: (Monoid a, Monoid b, Eq a, Eq b, Show a, Show b)
   => Gen a -> (a -> b) -> Test
 testMonoidMorphism gen f = testGroup "MonoidM"
-  [ "morphism-iden" ~: f mempty ~?= mempty
+  [ testCase "morphism-iden" $ f mempty @?= mempty
   , testProperty "morphism-compose" $
       forAll gen $ \x -> forAll gen $ \y ->
         f (x <> y) === f x <> f y
@@ -367,29 +395,3 @@
 
 testGenerator :: (Eq a, Show a) => Gen a -> (a -> Maybe b) -> Test
 testGenerator gen p = testProperty "generator" $ forAll gen (isJust . p)
-
--- * Utilities
-
--- Qualify test names
-rename :: [Test] -> [Test]
-rename = fmap (rename' "")
-
-rename' :: String -> Test -> Test
-rename' pfx (Test t) = Test t{ name = pfx ++ name t }
-rename' pfx (Group name conc tests)
-  = Group name conc (fmap (rename' (pfx ++ name ++ "/")) tests)
-rename' pfx (ExtraOptions opts test) = ExtraOptions opts (rename' pfx test)
-
-filterTests :: [Test] -> IO [Test]
-filterTests tests = do
-  getArgs <&> \case
-    [] -> tests
-    pfxs -> filterTests' pfxs tests
-
-filterTests' pfxs = (>>= filterTest pfxs)
-
-filterTest pfxs test@(Test t) = [test | any (`isPrefixOf` name t) pfxs]
-filterTest pfxs (Group name conc tests)
-  = let tests' = filterTests' pfxs tests
-    in [Group name conc tests' | (not . null) tests']
-filterTest pfxs (ExtraOptions opts test) = ExtraOptions opts <$> filterTest pfxs test
diff --git a/twentyseven.cabal b/twentyseven.cabal
--- a/twentyseven.cabal
+++ b/twentyseven.cabal
@@ -1,5 +1,5 @@
 name:                twentyseven
-version:             0.0.0
+version:             1.0.0
 synopsis:            Rubik's cube solver
 description:
   Solve 3×3×3 Rubik's cubes in the fewest possible moves. Or, if you can't
@@ -8,12 +8,18 @@
 license:             MIT
 license-file:        LICENSE
 author:              Li-yao Xia
-maintainer:          li-yao.xia@ens.fr
+maintainer:          lysxia@gmail.com
 category:            Algorithms
 build-type:          Simple
-extra-source-files:  README.md stack.yaml
-cabal-version:       >=1.10
+extra-doc-files:     README.md, CHANGELOG.md
+cabal-version:       2.0
+tested-with:
+  GHC == 8.6.5, GHC == 9.12.2
 
+source-repository head
+  type:     git
+  location: https://github.com/lysxia/twentyseven
+
 library
   exposed-modules:
     Data.Binary.Storable
@@ -56,19 +62,19 @@
     ViewPatterns
   build-depends:
     base >=4.8 && <5,
-    deepseq,
-    directory,
-    filepath,
-    heap >=1.0,
-    primitive >=0.6,
-    vector >=0.10,
-    containers >=0.5,
-    monad-loops,
-    MonadRandom,
-    mtl >= 2.1,
-    newtype >= 0.2,
-    ref-fd >=0.4,
-    template-haskell
+    deepseq < 1.7,
+    directory < 1.4,
+    filepath < 1.6,
+    heap >=1.0 && < 1.1,
+    primitive >=0.6 && < 0.10,
+    vector >=0.10 && < 0.14,
+    containers >=0.5 && < 0.9,
+    monad-loops >= 0.4 && < 0.5,
+    MonadRandom >= 0.4.2.2 && < 0.7,
+    mtl >= 2.1 && < 2.4,
+    newtype >= 0.2 && < 0.3,
+    ref-fd >=0.4 && < 0.6,
+    template-haskell < 2.24
   hs-source-dirs:      src
   default-language:    Haskell2010
   ghc-options:         -fwarn-unused-imports
@@ -81,25 +87,28 @@
     RecordWildCards
   build-depends:
     base >=4.8 && <5,
-    optparse-applicative,
-    time <1.6,
-    transformers,
+    optparse-applicative < 0.20,
+    time <1.15,
+    transformers < 0.7,
     twentyseven
   default-language:    Haskell2010
+  ghc-options: -Wall
 
 Test-Suite test-twentyseven
-  type: detailed-0.9
+  type: exitcode-stdio-1.0
   hs-source-dirs: test
-  test-module: Test
+  main-is: Test.hs
   other-extensions:
     LambdaCase
   build-depends:
     base >=4.8,
-    Cabal >=1.9.3,
-    cabal-test-quickcheck >=0.1.6,
-    HUnit-Plus >=1.1.0,
     QuickCheck >=2.8,
+    tagged >= 0.8.9,
+    tasty,
+    tasty-quickcheck,
+    tasty-hunit,
     split,
     vector,
     twentyseven
   default-language: Haskell2010
+  ghc-options: -Wall -Wno-name-shadowing -Wno-missing-signatures -Wno-unused-top-binds
