{-# LANGUAGE MultiWayIf        #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}
module HaskellCI.GitHub (
    makeGitHub,
    githubHeader,
    -- * For tests
    parseGitHubRepo,
) where

import HaskellCI.Prelude

import Control.Applicative (optional)

import qualified Crypto.Hash.SHA256              as SHA256
import qualified Data.Attoparsec.Text            as Atto
import qualified Data.Binary                     as Binary
import qualified Data.Binary.Put                 as Binary
import qualified Data.ByteString.Base16          as Base16
import qualified Data.ByteString.Char8           as BS8
import qualified Data.Map.Strict                 as Map
import qualified Data.Set                        as S
import qualified Data.Text                       as T
import qualified Distribution.Fields.Pretty      as C
import qualified Distribution.Package            as C
import qualified Distribution.Pretty             as C
import qualified Distribution.Types.VersionRange as C
import qualified Distribution.Version            as C

import Cabal.Project
import HaskellCI.Auxiliary
import HaskellCI.Compiler
import HaskellCI.Config
import HaskellCI.Config.ConstraintSet
import HaskellCI.Config.Docspec
import HaskellCI.Config.Doctest
import HaskellCI.Config.Installed
import HaskellCI.Config.Jobs
import HaskellCI.Config.PackageScope
import HaskellCI.Config.Ubuntu
import HaskellCI.Config.Validity
import HaskellCI.Ghcup
import HaskellCI.GitConfig
import HaskellCI.GitHub.Yaml
import HaskellCI.HeadHackage
import HaskellCI.Jobs
import HaskellCI.List
import HaskellCI.MonadErr
import HaskellCI.Package
import HaskellCI.SetupMethod
import HaskellCI.Sh
import HaskellCI.ShVersionRange
import HaskellCI.Tools
import HaskellCI.VersionInfo

-- $setup
-- >>> :set -XOverloadedStrings

-------------------------------------------------------------------------------
-- GitHub header
-------------------------------------------------------------------------------

githubHeader :: Bool -> [String] -> [String]
githubHeader insertVersion argv =
    [ "This GitHub workflow config has been generated by a script via"
    , ""
    , "  haskell-ci " ++ unwords [ "'" ++ a ++ "'" | a <- argv ]
    , ""
    , "To regenerate the script (for example after adjusting tested-with) run"
    , ""
    , "  haskell-ci regenerate"
    , ""
    , "For more information, see https://github.com/haskell-CI/haskell-ci"
    , ""
    ] ++
    verlines ++
    [ "REGENDATA " ++ if insertVersion then show (haskellCIVerStr, argv) else show argv
    , ""
    ]
  where
    verlines
        | insertVersion = [ "version: " ++ haskellCIVerStr , "" ]
        | otherwise     = []

-------------------------------------------------------------------------------
-- GitHub
-------------------------------------------------------------------------------

{-
GitHub Actions–specific notes:

* We use -j2 for parallelism, as GitHub's virtual machines use 2 cores, per
  https://docs.github.com/en/free-pro-team@latest/actions/reference/specifications-for-github-hosted-runners#supported-runners-and-hardware-resources.
-}

makeGitHub
    :: [String]
    -> Config
    -> GitConfig
    -> Project URI Void Package
    -> JobVersions
    -> Either HsCiError GitHub
makeGitHub _argv config@Config {..} gitconfig prj jobs@JobVersions {..} = do
    let envEnv = Map.fromList
            [ ("HCNAME", "${{ matrix.compiler }}")         -- e.g. ghc-8.8.4
            , ("HCKIND", "${{ matrix.compilerKind }}")     --      ghc
            , ("HCVER",  "${{ matrix.compilerVersion }}")  --      8.8.4
            ]

    -- Validity checks
    checkConfigValidity config jobs
    when (cfgSubmodules && cfgUbuntu < Focal) $
        throwErr $ ValidationError $ unwords
            [ "Using submodules on the GitHub Actions backend requires"
            , "Ubuntu 20.04 (Focal Fossa) or later."
            ]

    steps <- sequence $ buildList $ do
        githubRun "apt-get install" $ do
            sh "apt-get update"
            let corePkgs :: [String]
                corePkgs =
                    [ "gnupg"
                    , "ca-certificates"
                    , "dirmngr"
                    , "curl"
                    , "git"
                    , "software-properties-common"
                    , "libtinfo5"
                    ] ++
                    -- Installing libnuma-dev is required to work around
                    -- https://gitlab.haskell.org/haskell/ghcup-hs/-/blob/b0522507be6fa991a819aaf22f9a551757380821/README.md#libnuma-required
                    [ "libnuma-dev"
                    | GHC (C.mkVersion [8,4,4]) `elem` allVersions
                    , GHC (C.mkVersion [8,4,4]) & isGHCUP
                    ]

            sh $ "apt-get install -y --no-install-recommends " ++ unwords corePkgs

            -- additional packages
            unless (null cfgApt) $ sh $ "apt-get install -y " ++ unwords (S.toList cfgApt)

        githubRun "Install GHCup" $ do
            let ghcupVer = C.prettyShow cfgGhcupVersion
            sh $ "mkdir -p \"$HOME/.ghcup/bin\""
            sh $ "curl -sL https://downloads.haskell.org/ghcup/" ++ ghcupVer ++ "/x86_64-linux-ghcup-" ++ ghcupVer ++ " > \"$HOME/.ghcup/bin/ghcup\""
            sh $ "chmod a+x \"$HOME/.ghcup/bin/ghcup\""

        unless cabalPrerelease $ githubRun "Install cabal-install" $ do
            sh $ "\"$HOME/.ghcup/bin/ghcup\" install cabal " ++ cabalFullVer ++ " || (cat \"$HOME\"/.ghcup/logs/*.* && false)"
            tell_env "CABAL" $ "$HOME/.ghcup/bin/cabal-" ++ cabalFullVer ++ " -vnormal+nowrap"

        when cabalPrerelease $ githubRun "Install cabal-install (prerelease)" $ do
            sh "\"$HOME/.ghcup/bin/ghcup\" config add-release-channel prereleases;"
            sh $ "\"$HOME/.ghcup/bin/ghcup\" install cabal " ++ cabalFullVer ++ " || (cat \"$HOME\"/.ghcup/logs/*.* && false)"
            tell_env "CABAL" $ "$HOME/.ghcup/bin/cabal-" ++ cabalFullVer ++ " -vnormal+nowrap"

        -- todo: when any job uses hvr-ppa
        let whenWithinGhcRange :: Applicative f => VersionRange -> f () -> f ()
            whenWithinGhcRange vr m = when (any (`compilerWithinGhcRange` vr) allVersions) m

        whenWithinGhcRange (index cfgSetupMethods HVRPPA) $ githubRunIf' "Install GHC (hvr-ppa)" "matrix.setup-method == 'hvr-ppa'" envEnv $ do
            sh "apt-add-repository -y 'ppa:hvr/ghc'"
            sh "apt-get update"
            sh $ "apt-get install -y \"$HCNAME\""

            let hc = "$HCDIR/bin/$HCKIND"
            sh $ "HC=" ++ hc -- HC is an absolute path.
            tell_env "HC" "$HC"
            tell_env "HCPKG" $ hc ++ "-pkg"
            tell_env "HADDOCK" "$HCDIR/bin/haddock"

        let ghcupGhcEnv = do
                sh $ "HC=$(\"$HOME/.ghcup/bin/ghcup\" whereis ghc \"$HCVER\")"
                sh $ "HCPKG=$(echo \"$HC\" | sed 's#ghc$#ghc-pkg#')"
                sh $ "HADDOCK=$(echo \"$HC\" | sed 's#ghc$#haddock#')"
                tell_env "HC" "$HC"
                tell_env "HCPKG" "$HCPKG"
                tell_env "HADDOCK" "$HADDOCK"

        whenWithinGhcRange (index cfgSetupMethods GHCUP) $ githubRunIf' "Install GHC (GHCup)" "matrix.setup-method == 'ghcup'" envEnv $ do
            sh $ "\"$HOME/.ghcup/bin/ghcup\" install ghc \"$HCVER\" || (cat \"$HOME\"/.ghcup/logs/*.* && false)"
            ghcupGhcEnv

        whenWithinGhcRange (index cfgSetupMethods GHCUPvanilla) $ githubRunIf' "Install GHC (GHCup vanilla)" "matrix.setup-method == 'ghcup-vanilla'" envEnv $ do
            sh $ "\"$HOME/.ghcup/bin/ghcup\" -s https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-vanilla-0.0.9.yaml install ghc \"$HCVER\" || (cat \"$HOME\"/.ghcup/logs/*.* && false)"
            ghcupGhcEnv

        whenWithinGhcRange (index cfgSetupMethods GHCUPprerelease) $ githubRunIf' "Install GHC (GHCup prerelease)" "matrix.setup-method == 'ghcup-prerelease'" envEnv $ do
            sh "\"$HOME/.ghcup/bin/ghcup\" config add-release-channel prereleases"
            sh $ "\"$HOME/.ghcup/bin/ghcup\" install ghc \"$HCVER\" || (cat \"$HOME\"/.ghcup/logs/*.* && false)"
            ghcupGhcEnv

        githubRun' "Set PATH and environment variables" envEnv $ do
            echo_to "$GITHUB_PATH" "$HOME/.cabal/bin"

            tell_env "LANG" "C.UTF-8"

            tell_env "CABAL_DIR"    "$HOME/.cabal"
            tell_env "CABAL_CONFIG" "$HOME/.cabal/config"

            sh "HCNUMVER=$(${HC} --numeric-version|perl -ne '/^(\\d+)\\.(\\d+)\\.(\\d+)(\\.(\\d+))?$/; print(10000 * $1 + 100 * $2 + ($3 == 0 ? $5 != 1 : $3))')"
            tell_env "HCNUMVER" "$HCNUMVER"

            if_then_else (Range cfgTests)
                (tell_env' "ARG_TESTS" "--enable-tests")
                (tell_env' "ARG_TESTS" "--disable-tests")
            if_then_else (Range cfgBenchmarks)
                (tell_env' "ARG_BENCH" "--enable-benchmarks")
                (tell_env' "ARG_BENCH" "--disable-benchmarks")
            if_then_else (Range cfgHeadHackage \/ RangePoints (S.singleton GHCHead))
                (tell_env' "HEADHACKAGE" "true")
                (tell_env' "HEADHACKAGE" "false")

            tell_env "ARG_COMPILER" "--$HCKIND --with-compiler=$HC"

        githubRun "env" $ do
            sh "env"

        githubRun "write cabal config" $ do
            sh "mkdir -p $CABAL_DIR"
            cat "$CABAL_CONFIG" $ unlines
                [ "remote-build-reporting: anonymous"
                , "write-ghc-environment-files: never"
                , "remote-repo-cache: $CABAL_DIR/packages"
                , "logs-dir:          $CABAL_DIR/logs"
                , "world-file:        $CABAL_DIR/world"
                , "extra-prog-path:   $CABAL_DIR/bin"
                , "symlink-bindir:    $CABAL_DIR/bin"
                , "installdir:        $CABAL_DIR/bin"
                , "build-summary:     $CABAL_DIR/logs/build.log"
                , "store-dir:         $CABAL_DIR/store"
                , "install-dirs user"
                , "  prefix: $CABAL_DIR"
                , "repository hackage.haskell.org"
                , "  url: http://hackage.haskell.org/"
                ]

            -- Add head.hackage repository to ~/.cabal/config
            -- (locally you want to add it to cabal.project)
            when anyJobUsesHeadHackage $ sh $ concat $
                [ "if $HEADHACKAGE; then\n"
                , catCmd "$CABAL_CONFIG" $ unlines $ headHackageRepoStanza cfgHeadHackageOverride
                , "\nfi"
                ]

            -- Cabal jobs
            for_ (cfgJobs >>= cabalJobs) $ \n ->
                cat "$CABAL_CONFIG" $ unlines
                    [ "jobs: " ++ show n
                    ]

            -- GHC jobs + ghc-options
            for_ (cfgJobs >>= ghcJobs) $ \m -> do
                sh_if (Range $ C.orLaterVersion (C.mkVersion [7,8])) $ "GHCJOBS=-j" ++ show m

            cat "$CABAL_CONFIG" $ unlines
                [ "program-default-options"
                , "  ghc-options: $GHCJOBS +RTS -M3G -RTS"
                ]

            sh "cat $CABAL_CONFIG"

        githubRun "versions" $ do
            sh "$HC --version || true"
            sh "$HC --print-project-git-commit-id || true"
            sh "$CABAL --version || true"

        githubRun "update cabal index" $ do
            sh "$CABAL v2-update -v"

        let toolsConfigHash :: String
            toolsConfigHash = take 8 $ BS8.unpack $ Base16.encode $ SHA256.hashlazy $ Binary.runPut $ do
                Binary.put cfgDoctest
                Binary.put cfgSetupMethods

        when (doctestEnabled) $ githubUses "cache (tools)" "actions/cache/restore@v4"
            [ ("key", "${{ runner.os }}-${{ matrix.compiler }}-tools-" ++ toolsConfigHash)
            , ("path", "~/.haskell-ci-tools")
            ]

        githubRun "install cabal-plan" $ do
            sh "mkdir -p $HOME/.cabal/bin"
            sh "curl -sL https://github.com/haskell-hvr/cabal-plan/releases/download/v0.7.3.0/cabal-plan-0.7.3.0-x86_64-linux.xz > cabal-plan.xz"
            sh "echo 'f62ccb2971567a5f638f2005ad3173dba14693a45154c1508645c52289714cb2  cabal-plan.xz' | sha256sum -c -"
            sh "xz -d < cabal-plan.xz > $HOME/.cabal/bin/cabal-plan"
            sh "rm -f cabal-plan.xz"
            sh "chmod a+x $HOME/.cabal/bin/cabal-plan"
            sh "cabal-plan --version"

        when docspecEnabled $ githubRun "install cabal-docspec" $ do
            let hash = cfgDocspecHash cfgDocspec
                url  = cfgDocspecUrl cfgDocspec
            sh "mkdir -p $HOME/.cabal/bin"
            sh $ "curl -sL " ++ url ++ " > cabal-docspec.xz"
            sh $ "echo '" ++ hash ++ "  cabal-docspec.xz' | sha256sum -c -"
            sh "xz -d < cabal-docspec.xz > $HOME/.cabal/bin/cabal-docspec"
            sh "rm -f cabal-docspec.xz"
            sh "chmod a+x $HOME/.cabal/bin/cabal-docspec"
            sh "cabal-docspec --version"

        let doctestVersionConstraint
                | C.isAnyVersion (cfgDoctestVersion cfgDoctest) = ""
                | otherwise = " --constraint='doctest " ++ C.prettyShow (cfgDoctestVersion cfgDoctest) ++ "'"
        when doctestEnabled $ githubRun "install doctest" $ do
            let range = Range (cfgDoctestEnabled cfgDoctest) /\ doctestJobVersionRange
            sh_if range $ "$CABAL --store-dir=$HOME/.haskell-ci-tools/store v2-install $ARG_COMPILER --ignore-project -j2 doctest" ++ doctestVersionConstraint
            sh_if range "doctest --version"

        when (doctestEnabled) $ githubUsesIf "save cache (tools)" "actions/cache/save@v4" "always()"
            [ ("key", "${{ runner.os }}-${{ matrix.compiler }}-tools-" ++ toolsConfigHash)
            , ("path", "~/.haskell-ci-tools")
            ]

        githubUses "checkout" "actions/checkout@v5" $ buildList $ do
            item ("path", "source")
            when cfgSubmodules $
                item ("submodules", "true")

        githubRun "initial cabal.project for sdist" $ do
            sh "touch cabal.project"
            for_ pkgs $ \pkg ->
                echo_if_to (RangePoints $ pkgJobs pkg) "cabal.project" $ "packages: $GITHUB_WORKSPACE/source/" ++ pkgDir pkg
            sh "cat cabal.project"

        githubRun "sdist" $ do
            sh "mkdir -p sdist"
            sh "$CABAL sdist all --output-dir $GITHUB_WORKSPACE/sdist"

        githubRun "unpack" $ do
            sh "mkdir -p unpacked"
            sh "find sdist -maxdepth 1 -type f -name '*.tar.gz' -exec tar -C $GITHUB_WORKSPACE/unpacked -xzvf {} \\;"

        githubRun "generate cabal.project" $ do
            for_ pkgs $ \Pkg{pkgName} -> do
                sh $ pkgNameDirVariable' pkgName ++ "=\"$(find \"$GITHUB_WORKSPACE/unpacked\" -maxdepth 1 -type d -regex '.*/" ++ pkgName ++ "-[0-9.]*')\""
                tell_env (pkgNameDirVariable' pkgName) (pkgNameDirVariable pkgName)

            sh "rm -f cabal.project cabal.project.local"
            sh "touch cabal.project"
            sh "touch cabal.project.local"

            for_ pkgs $ \pkg ->
                echo_if_to (RangePoints $ pkgJobs pkg) "cabal.project" $ "packages: " ++ pkgNameDirVariable (pkgName pkg)

            -- per package options
            case cfgErrorMissingMethods of
                PackageScopeNone  -> pure ()
                PackageScopeLocal -> for_ pkgs $ \Pkg{pkgName,pkgJobs} -> do
                    let range = Range (C.orLaterVersion (C.mkVersion [8,2])) /\ RangePoints pkgJobs
                    echo_if_to range "cabal.project" $ "package " ++ pkgName
                    echo_if_to range "cabal.project" $ "    ghc-options: -Werror=missing-methods -Werror=missing-fields"
                PackageScopeAll   -> cat "cabal.project" $ unlines
                    [ "package *"
                    , "  ghc-options: -Werror=missing-methods -Werror=missing-fields"
                    ]

            -- -Werror-unused-pkgs
            for_ pkgs $ \Pkg{pkgName,pkgJobs} -> do
                -- is introduced in 8.10.
                let range = Range cfgErrorUnusedPkgs /\ Range (C.orLaterVersion (C.mkVersion [8,10])) /\ RangePoints pkgJobs
                echo_if_to range "cabal.project" $ "package " ++ pkgName
                echo_if_to range "cabal.project" $ "    ghc-options: -Werror=unused-packages"

            -- -Werror-unused-pkgs
            for_ pkgs $ \Pkg{pkgName,pkgJobs} -> do
                -- is introduced in 8.10.
                let range = Range cfgErrorIncompletePatterns /\ RangePoints pkgJobs
                echo_if_to range "cabal.project" $ "package " ++ pkgName
                echo_if_to range "cabal.project" $ "    ghc-options: -Werror=incomplete-patterns -Werror=incomplete-uni-patterns"

            -- extra cabal.project fields
            cat "cabal.project" $ C.showFields' (const C.NoComment) (const id) 2 $ extraCabalProjectFields "$GITHUB_WORKSPACE/source/"

            -- If using head.hackage, allow building with newer versions of GHC boot libraries.
            -- Note that we put this in a cabal.project file, not ~/.cabal/config, in order to avoid
            -- https://github.com/haskell/cabal/issues/7291.
            when anyJobUsesHeadHackage $ sh $ concat $
                [ "if $HEADHACKAGE; then\n"
                , "echo \"allow-newer: $($HCPKG list --simple-output | sed -E 's/([a-zA-Z-]+)-[0-9.]+/*:\\1,/g')\" >> cabal.project\n"
                , "fi"
                ]

            -- also write cabal.project.local file with
            -- @
            -- constraints: base installed
            -- constraints: array installed
            -- ...
            --
            -- omitting any local package names
            case normaliseInstalled cfgInstalled of
                InstalledDiff pns -> sh $ unwords
                    [ "$HCPKG list --simple-output --names-only"
                    , "| perl -ne 'for (split /\\s+/) { print \"constraints: any.$_ installed\\n\" unless /" ++ re ++ "/; }'"
                    , ">> cabal.project.local"
                    ]
                  where
                    pns' = S.map C.unPackageName pns `S.union` foldMap (S.singleton . pkgName) pkgs
                    re = "^(" ++ intercalate "|" (S.toList pns') ++ ")$"

                InstalledOnly pns | not (null pns') -> cat "cabal.project.local" $ unlines
                    [ "constraints: any." ++ pkg ++ " installed"
                    | pkg <- S.toList pns'
                    ]
                  where
                    pns' = S.map C.unPackageName pns `S.difference` foldMap (S.singleton . pkgName) pkgs

                -- otherwise: nothing
                _ -> pure ()

            sh "cat cabal.project"
            sh "cat cabal.project.local"

        githubRun "dump install plan" $ do
            sh "$CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dry-run all"
            sh "cabal-plan"

        -- This a hack. https://github.com/actions/cache/issues/109
        -- Hashing Java - Maven style.
        githubUses "restore cache" "actions/cache/restore@v4"
            [ ("key", "${{ runner.os }}-${{ matrix.compiler }}-${{ github.sha }}")
            , ("restore-keys", "${{ runner.os }}-${{ matrix.compiler }}-")
            , ("path", "~/.cabal/store")
            ]

        -- install dependencies
        when cfgInstallDeps $ githubRun "install dependencies" $ do
            sh "$CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks --dependencies-only -j2 all"
            sh "$CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH --dependencies-only -j2 all"

        -- build w/o tests benchs
        unless (equivVersionRanges C.noVersion cfgNoTestsNoBench) $ githubRun "build w/o tests" $ do
            sh "$CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all"

        -- build
        githubRun "build" $ do
            sh "$CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH all --write-ghc-environment-files=always"

        -- tests
        githubRun "tests" $ do
            let range = RangeGHC /\ Range (cfgTests /\ cfgRunTests) /\ hasTests
            sh_if range $ "$CABAL v2-test $ARG_COMPILER $ARG_TESTS $ARG_BENCH all" ++ testShowDetails

        -- doctest
        when doctestEnabled $ githubRun "doctest" $ do
            let doctestOptions = unwords $ cfgDoctestOptions cfgDoctest

            unless (null $ cfgDoctestFilterEnvPkgs cfgDoctest) $ do
                -- cabal-install mangles unit ids on the OSX,
                -- removing the vowels to make filepaths shorter
                let manglePkgNames :: String -> [String]
                    manglePkgNames n
                        | null macosVersions = [n]
                        | otherwise          = [n, filter notVowel n]
                      where
                        notVowel c = notElem c ("aeiou" :: String)
                let filterPkgs = intercalate "|" $ concatMap (manglePkgNames . C.unPackageName) $ cfgDoctestFilterEnvPkgs cfgDoctest
                sh $ "perl -i -e 'while (<ARGV>) { print unless /package-id\\s+(" ++ filterPkgs ++ ")-\\d+(\\.\\d+)*/; }' .ghc.environment.*"

            for_ pkgs $ \Pkg{pkgName,pkgGpd,pkgJobs} ->
                when (C.mkPackageName pkgName `notElem` cfgDoctestFilterSrcPkgs cfgDoctest) $ do
                    for_ (doctestArgs pkgGpd) $ \args -> do
                        let args' = unwords args
                        let vr = Range (cfgDoctestEnabled cfgDoctest)
                              /\ doctestJobVersionRange
                              /\ RangePoints pkgJobs

                        unless (null args) $ do
                            change_dir_if vr $ pkgNameDirVariable pkgName
                            sh_if vr $ "doctest " ++ doctestOptions ++ " " ++ args'

        -- docspec
        when docspecEnabled $ githubRun "docspec" $ do
            -- docspec doesn't work with non-GHC (i.e. GHCJS)
            let docspecRange' = docspecRange /\ RangeGHC
            -- we need to rebuild, if tests screwed something.
            sh_if docspecRange' "$CABAL v2-build $ARG_COMPILER $ARG_TESTS $ARG_BENCH all"
            sh_if docspecRange' cabalDocspec

        -- cabal check
        when cfgCheck $ githubRun "cabal check" $ do
            for_ pkgs $ \Pkg{pkgName,pkgJobs} -> do
                let range = RangePoints pkgJobs
                change_dir_if range $ pkgNameDirVariable pkgName
                sh_if range "${CABAL} -vnormal check"

        -- haddock
        when runHaddock $ githubRun "haddock" $ do
            let range = RangeGHC /\ Range cfgHaddock
            -- disable-documentation disables docs in deps: https://github.com/haskell/cabal/issues/7462
            sh_if range $ "$CABAL v2-haddock --disable-documentation" ++ haddockFlags ++ " $ARG_COMPILER --with-haddock $HADDOCK $ARG_TESTS $ARG_BENCH all"

        -- unconstrained build
        unless (equivVersionRanges C.noVersion cfgUnconstrainted) $ githubRun "unconstrained build" $ do
            let range = Range cfgUnconstrainted
            sh_if range "rm -f cabal.project.local"
            sh_if range "$CABAL v2-build $ARG_COMPILER --disable-tests --disable-benchmarks all"

        -- constraint sets
        unless (null cfgConstraintSets) $ githubRun "prepare for constraint sets" $ do
            sh "rm -f cabal.project.local"

        for_ cfgConstraintSets $ \cs -> githubRun ("constraint set " ++ csName cs) $ do
            let range
                  | csGhcjs cs  = Range (csGhcVersions cs)
                  | otherwise   = RangeGHC /\ Range (csGhcVersions cs)

            let sh_cs           = sh_if range
            let sh_cs' r        = sh_if (range /\ r)
            let testFlag        = if csTests cs then "--enable-tests" else "--disable-tests"
            let benchFlag       = if csBenchmarks cs then "--enable-benchmarks" else "--disable-benchmarks"
            let constraintFlags = map (\x ->  "--constraint='" ++ x ++ "'") (csConstraints cs)
            let preferFlags     = if csPreferOldest cs then ["--prefer-oldest"] else []
            let allFlags        = unwords (testFlag : benchFlag : constraintFlags ++ preferFlags)

            sh_cs $ "$CABAL v2-build $ARG_COMPILER " ++ allFlags ++ " all --dry-run"
            sh_cs $ "cabal-plan topo | sort"
            when cfgInstallDeps $ sh_cs $ "$CABAL v2-build $ARG_COMPILER " ++ allFlags ++ " --dependencies-only -j2 all"
            sh_cs $ "$CABAL v2-build $ARG_COMPILER " ++ allFlags ++ " all"
            when (docspecEnabled && csDocspec cs) $
                sh_cs' docspecRange cabalDocspec
            when (csRunTests cs) $
                sh_cs' hasTests $ "$CABAL v2-test $ARG_COMPILER " ++ allFlags ++ " all"
            when (csHaddock cs) $
                sh_cs $ "$CABAL v2-haddock --disable-documentation" ++ haddockFlags ++ " $ARG_COMPILER " ++ withHaddock ++ " " ++ allFlags ++ " all"

        githubUsesIf "save cache" "actions/cache/save@v4" "always()"
          [ ("key", "${{ runner.os }}-${{ matrix.compiler }}-${{ github.sha }}")
          , ("path", "~/.cabal/store")
          ]

    -- assembling everything
    return GitHub
        { ghName = actionName
        , ghOn = GitHubOn
            { ghBranches = cfgOnlyBranches
            }
        , ghJobs = Map.fromList $ buildList $ do
            item (mainJobName, GitHubJob
                { ghjName            = actionName ++ " - Linux - ${{ matrix.compiler }}"
                  -- NB: The Ubuntu version used in `runs-on` isn't
                  -- particularly important since we use a Docker container.
                , ghjRunsOn          = ghcRunsOnVer
                , ghjNeeds           = []
                , ghjSteps           = steps
                , ghjIf              = Nothing
                , ghjContainer       = Just $ "buildpack-deps:" ++ ubuntuVer
                , ghjContinueOnError = Just "${{ matrix.allow-failure }}"
                , ghjServices        = mconcat
                    [ Map.singleton "postgres" postgresService | cfgPostgres ]
                , ghjTimeout         = max 10 cfgTimeoutMinutes
                , ghjMatrix          = concat $
                    -- we can have multiple setup methods for the same
                    -- compiler version, if jobs overlap.
                    [ [ GitHubMatrixEntry
                        { ghmeCompiler     = translateCompilerVersion cfgVersionMapping $ compiler
                        , ghmeAllowFailure =
                               isGHCHead compiler
                            || maybeGHC False (`C.withinRange` cfgAllowFailures) compiler
                        , ghmeSetupMethod = sp
                        }
                        | sp <- [GHCUP, GHCUPvanilla, GHCUPprerelease, HVRPPA]
                        , compilerWithinGhcRange compiler $ index cfgSetupMethods sp
                      ]
                      | compiler <- reverse $ toList linuxVersions
                      , compiler /= GHCHead -- TODO: Make this work
                                          -- https://github.com/haskell-CI/haskell-ci/issues/458
                      ]
                })

            unless (null cfgIrcChannels) $
                ircJob actionName mainJobName projectName config gitconfig
        }
  where
    actionName  = fromMaybe "Haskell-CI" cfgGitHubActionName
    mainJobName = "linux"

    ubuntuVer    = showUbuntu cfgUbuntu
    cabalFullVer = maybe "head" translateCabalVersion cfgCabalInstallVersion

    Auxiliary {..} = auxiliary config prj jobs

    -- job to be setup with ghcup
    isGHCUP :: CompilerVersion -> Bool
    isGHCUP v = compilerWithinRange v (RangeGHC /\ Range (index cfgSetupMethods GHCUP))

    -- step primitives
    githubRun' :: String -> Map.Map String String ->  ShM () -> ListBuilder (Either HsCiError GitHubStep) ()
    githubRun' name env shm = item $ do
        shs <- runSh shm
        return $ GitHubStep name Nothing $ Left $ GitHubRun shs env

    githubRun :: String -> ShM () -> ListBuilder (Either HsCiError GitHubStep) ()
    githubRun name = githubRun' name mempty

    githubRunIf' :: String -> String -> Map.Map String String -> ShM () -> ListBuilder (Either HsCiError GitHubStep) ()
    githubRunIf' name if_ env shm = item $ do
        shs <- runSh shm
        return $ GitHubStep name (Just if_) $ Left $ GitHubRun shs env

    githubUses :: String -> String -> [(String, String)] -> ListBuilder (Either HsCiError GitHubStep) ()
    githubUses name action with = item $ return $
        GitHubStep name Nothing $ Right $ GitHubUses action (Map.fromList with)

    githubUsesIf :: String -> String -> String -> [(String, String)] -> ListBuilder (Either HsCiError GitHubStep) ()
    githubUsesIf name action if_ with = item $ return $
        GitHubStep name (Just if_) $ Right $ GitHubUses action (Map.fromList with)

    -- shell primitives
    echo_to' :: FilePath -> String -> String
    echo_to' fp s = "echo " ++ show s ++ " >> " ++ fp

    echo_to :: FilePath -> String -> ShM ()
    echo_to fp s = sh $ echo_to' fp s

    echo_if_to :: CompilerRange -> FilePath -> String -> ShM ()
    echo_if_to range fp s = sh_if range $ echo_to' fp s

    change_dir_if :: CompilerRange -> String -> ShM ()
    change_dir_if range dir = sh_if range ("cd " ++ dir ++ " || false")

    tell_env' :: String -> String -> String
    tell_env' k v = "echo " ++ show (k ++ "=" ++ v) ++ " >> \"$GITHUB_ENV\""

    tell_env :: String -> String -> ShM ()
    tell_env k v = sh $ tell_env' k v

    if_then_else :: CompilerRange -> String -> String -> ShM ()
    if_then_else range con alt
        | all (`compilerWithinRange` range) allVersions       = sh con
        | not $ any (`compilerWithinRange` range) allVersions = sh alt
        | otherwise = sh $ unwords
        [ "if ["
        , compilerVersionArithPredicate allVersions range
        , "-ne 0 ]"
        , "; then"
        , con
        , ";"
        , "else"
        , alt
        , ";"
        , "fi"
        ]

    sh_if :: CompilerRange -> String -> ShM ()
    sh_if range con
        | all (`compilerWithinRange` range) allVersions       = sh con
        | not $ any (`compilerWithinRange` range) allVersions = pure ()
        | otherwise = sh $ unwords
        [ "if ["
        , compilerVersionArithPredicate allVersions range
        , "-ne 0 ]"
        , "; then"
        , con
        , ";"
        , "fi"
        ]

    -- Needed to work around haskell/cabal#6214
    withHaddock :: String
    withHaddock = "--with-haddock $HADDOCK"

    cabalDocspec :: String
    cabalDocspec =
      let docspecOptions = cfgDocspecOptions cfgDocspec in
      unwords $ "cabal-docspec $ARG_COMPILER" : docspecOptions

    docspecRange :: CompilerRange
    docspecRange = Range (cfgDocspecEnabled cfgDocspec)

postgresService :: GitHubService
postgresService = GitHubService
    { ghServImage   = "postgres:14"
    , ghServOptions = Just "--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5"
    , ghServEnv     = Map.fromList
          [ ("POSTGRES_PASSWORD", "postgres")
          ]
    }

ircJob :: String -> String -> String -> Config -> GitConfig -> ListBuilder (String, GitHubJob) ()
ircJob actionName mainJobName projectName cfg gitconfig = item ("irc", GitHubJob
    { ghjName            = actionName ++ " (IRC notification)"
    , ghjRunsOn          = ghcRunsOnVer
    , ghjNeeds           = [mainJobName]
    , ghjIf              = jobCondition
    , ghjContainer       = Nothing
    , ghjContinueOnError = Nothing
    , ghjMatrix          = []
    , ghjServices        = mempty
    , ghjSteps           = [ ircStep serverChannelName success
                           | serverChannelName <- serverChannelNames
                           , success <- [True, False]
                           ]
    , ghjTimeout         = 10
    })
  where
    serverChannelNames = cfgIrcChannels cfg

    jobCondition :: Maybe String
    jobCondition
        | cfgIrcIfInOriginRepo cfg
        , Just url <- Map.lookup "origin" (gitCfgRemotes gitconfig)
        , Just repo <- parseGitHubRepo url

        = Just
        $ "${{ always() && (github.repository == '" ++ T.unpack repo ++ "') }}"

        | otherwise
        = Just "${{ always() }}"
        -- Use always() above to ensure that the IRC job will still run even if
        -- the build job itself fails (see #437).

    ircStep :: String -> Bool -> GitHubStep
    ircStep serverChannelName success =
        let (serverName, channelName) = break (== '#') serverChannelName

            result | success   = "success"
                   | otherwise = "failure"

            resultPastTense | success   = "succeeded"
                            | otherwise = "failed"

            eqCheck | success   = "=="
                    | otherwise = "!="

            condition =  "needs." ++ mainJobName ++ ".result " ++ eqCheck ++ " 'success'" in

        GitHubStep ("IRC " ++ result ++ " notification (" ++ serverChannelName ++ ")") (Just condition) $ Right $
        GitHubUses "Gottox/irc-message-action@v2"
                    $
        Map.fromList $ buildList $ do
            item ("server",   serverName)
            item ("channel",  channelName)
            item ("nickname", fromMaybe "github-actions" $ cfgIrcNickname cfg)
            for_ (cfgIrcPassword cfg) $ \p ->
                item ("sasl_password", p)
            item ("message",  "\x0313" ++ projectName ++ "\x03/\x0306${{ github.ref }}\x03 "
                                       ++ "\x0314${{ github.sha }}\x03 "
                                       ++ "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} "
                                       ++ "The build " ++ resultPastTense ++ ".")


catCmd :: FilePath -> String -> String
catCmd path contents = concat
    [ "cat >> " ++ path ++ " <<EOF\n"
    , contents
    , "EOF"
    ]

cat :: FilePath -> String -> ShM ()
cat path contents = sh $ catCmd path contents

-- | GitHub is very lenient and undocumented. We accept something.
-- Please, write a patch, if you need an extra scheme to be accepted.
--
-- >>> parseGitHubRepo "git@github.com:haskell-CI/haskell-ci.git"
-- Just "haskell-CI/haskell-ci"
--
-- >>> parseGitHubRepo "git@github.com:haskell-CI/haskell-ci"
-- Just "haskell-CI/haskell-ci"
--
-- >>> parseGitHubRepo "https://github.com/haskell-CI/haskell-ci.git"
-- Just "haskell-CI/haskell-ci"
--
-- >>> parseGitHubRepo "https://github.com/haskell-CI/haskell-ci"
-- Just "haskell-CI/haskell-ci"
--
-- >>> parseGitHubRepo "git://github.com/haskell-CI/haskell-ci"
-- Just "haskell-CI/haskell-ci"
--
parseGitHubRepo :: Text -> Maybe Text
parseGitHubRepo t =
    either (const Nothing) Just $ Atto.parseOnly (parser <* Atto.endOfInput) t
  where
    parser :: Atto.Parser Text
    parser = sshP <|> httpsP

    sshP :: Atto.Parser Text
    sshP = do
        _ <- optional (Atto.string "git://")
        _ <- Atto.string "git@github.com:"
        repo <- Atto.takeWhile (/= '.')
        _ <- optional (Atto.string ".git")
        return repo

    httpsP :: Atto.Parser Text
    httpsP = do
        _ <- Atto.string "https" <|> Atto.string "git"
        _ <- Atto.string "://github.com/"
        repo <- Atto.takeWhile (/= '.')
        _ <- optional (Atto.string ".git")
        return repo

-- NB: The Ubuntu version used in `runs-on` isn't particularly important since
-- we use a Docker container. We do make an attempt to keep it relatively up to
-- date to ensure that it runs on a version of Ubuntu that GitHub Actions
-- runners support.
ghcRunsOnVer :: String
ghcRunsOnVer = "ubuntu-24.04"

translateCompilerVersion :: Map Version Version -> CompilerVersion -> CompilerVersion
translateCompilerVersion m (GHC v) = GHC (Map.findWithDefault v v m)
translateCompilerVersion _ x       = x
