堆栈 (Haskell) 使用 GitHub 操作构建源文件缓存

Stack (Haskell) build cache of source files with GitHub Actions

当使用 stack build 在本地构建我的 Haskell 项目时,仅重新编译更改的源文件。不幸的是,我无法让 Stack 在 GitHub Actions 上表现得像这样。请问有什么建议吗?

例子

我用 Lib.hsFib.hs 创建了一个简单的示例,我什至检查缓存的 .stack-work 文件夹是否在构建之间更新,但它总是编译两个文件,即使只更改一个文件。

示例如下:

  1. (未使用缓存,同时构建 Lib.hsFib.hs + 依赖项):https://github.com/MarekSuchanek/stack-test/runs/542163994
  2. (仅 Lib.hs 更改,构建 Lib.hsFib.hs):https://github.com/MarekSuchanek/stack-test/runs/542174351

我可以从日志(详细堆栈)中观察到缓存中的某些内容正在更新,但我完全不清楚是什么以及为什么。它正确地发现只有 Lib.hs 被改变了:“stack-test-0.1.0.0: unregistering (local file changes: src/Lib.hs)” 所以我不明白为什么所有的都被编译。我注意到在 2 中 Fib.hi 没有在 .stack-work 中更新,但其他(Fib.oFib.dyn_hiFib.dyn_o)是。

备注

~/.stack 的缓存是可以的,并且在没有更改源文件时也可以不构建。当然,这是一个虚拟的例子,但我们有不同的项目有更多的源文件,这将显着加快构建速度。更改非源文件(例如 README 文件)时,没有按预期构建任何内容。

这个问题的罪魁祸首是堆栈使用时间戳(就像许多其他工具一样)来确定源文件是否已更改。当您在 CI 上恢复缓存并正确执行时,none 的依赖项将得到重建,但源文件的问题是当 CI 提供程序为您克隆一个 repo 时,回购协议中所有文件的时间戳都设置为克隆时的日期和时间。

希望重新编译未更改的源文件的原因现在是有意义的。我们如何解决这个问题。获得它的唯一真正方法是恢复最后一次更改特定文件的 git 提交的时间戳。我很久以前就注意到了这一点,谷歌搜索给了我一些关于 SO 的答案,我认为这是其中之一:Restore a file's modification time in Git

我对其进行了一些修改以满足我的需要,这就是我最终得到的:

  git ls-tree -r --name-only HEAD | while read filename; do
    TS="$(git log -1 --format="%ct" -- ${filename})"
    touch "${filename}" -mt "$(date --date="@$TS" "+%Y%m%d%H%M.%S")"
  done

那个工作人员在 Ubuntu CI 上对我来说很棒一段时间,但是用 bash 以 OS 不可知的方式解决这个问题不是我想要的当我需要设置 Azure CI 时执行此操作。出于这个原因,我写了一个 Haskell 脚本,适用于所有 GHC-8.2 版本和更新版本,不需要任何非核心依赖项。我将它用于我的所有项目,我将在此处嵌入它的精华,同时提供 link to a permanent gist

main = do
  args <- getArgs
  let rev = case args of
        [] -> "HEAD"
        (x:_) -> x
  fs <- readProcess "git" ["ls-tree", "-r", "-t", "--full-name", "--name-only", rev] ""
  let iso8601 = iso8601DateFormat (Just "%H:%M:%S%z")
      restoreFileModtime fp = do
        modTimeStr <- readProcess "git" ["log", "--pretty=format:%cI", "-1", rev, "--", fp] ""
        modTime <- parseTimeM True defaultTimeLocale iso8601 modTimeStr
        setModificationTime fp modTime
        putStrLn $ "[" ++ modTimeStr ++ "] " ++ fp
  putStrLn "Restoring modification time for all these files:"
  mapM_ restoreFileModtime $ lines fs

您将如何在没有太多开销的情况下使用它。诀窍是:

  • 使用stack自身到运行脚本
  • 使用与项目完全相同的解析器。

以上两点将确保不会安装多余的依赖项或ghc 版本。总而言之,只需要两件事是 stackcurlwget 之类的东西,它可以跨平台工作:

# Script for restoring source files modification time from commit to avoid recompilation.
curl -sSkL https://gist.githubusercontent.com/lehins/fd36a8cc8bf853173437b17f6b6426ad/raw/4702d0252731ad8b21317375e917124c590819ce/git-modtime.hs -o git-modtime.hs
# Restore mod time and setup ghc, if it wasn't restored from cache
stack script --resolver ${RESOLVER} git-modtime.hs --package base --package time --package directory --package process

这是一个使用这种方法的真实项目,您可以深入了解它是如何工作的:massiv-io

编辑 @Simon Michael 在评论中提到他无法在本地重现此问题。这样做的原因是 CI 上的所有内容都与本地的不一样。很多时候绝对路径是不同的,例如,可能是我现在想不到的其他事情。这些东西与源文件时间戳一起导致源文件的重新编译。

例如,按照以下步骤操作,您会发现您的项目将被重新编译:

~/tmp$ git clone git@github.com:fpco/safe-decimal.git
~/tmp$ cd safe-decimal
~/tmp/safe-decimal$ stack build
safe-decimal> configure (lib)
[1 of 2] Compiling Main
...
Configuring safe-decimal-0.2.0.0...
safe-decimal> build (lib)
Preprocessing library for safe-decimal-0.2.0.0..
Building library for safe-decimal-0.2.0.0..
[1 of 3] Compiling Numeric.Decimal.BoundedArithmetic
[2 of 3] Compiling Numeric.Decimal.Internal
[3 of 3] Compiling Numeric.Decimal
...
~/tmp/safe-decimal$ cd ../
~/tmp$ mv safe-decimal safe-decimal-moved
~/tmp$ cd safe-decimal-moved/
~/tmp/safe-decimal-moved$ stack build
safe-decimal-0.2.0.0: unregistering (old configure information not found)
safe-decimal> configure (lib)
[1 of 2] Compiling Main
...

您会看到项目的位置触发了项目构建。尽管项目本身是重建的,但您会注意到 none 的源文件被重新编译了。现在,如果您将该过程与源文件的 touch 组合,该源文件将被重新编译。

总结一下:

  • 环境可能导致项目重建
  • 源文件的内容会导致源文件(以及依赖它的其他文件)被重新编译
  • 环境和源文件内容或时间戳的变化会导致项目和源文件重新编译

我已经为此提供了 PR 修复,因此不再依赖修改后的时间!