Getting a UNIX epoch timestamp for a reproducible build’s SOURCE_DATE_EPOCH environment variable for a file from the last git commit inside a Makefile.

1. the thing

define source_epoch
SOURCE_DATE_EPOCH=$(shell (git log -1 --pretty=%ct -- $(1); date +%s) | head -1)
endef


%.out: %.in
	$(call source_epoch, $<) command $< $@

2. Introduction

This post is about getting the “Last updated: -----” timestamp on a rendered page with Asciidoctor to be the latest Git commit that touched the file.

My solution before this was almost good, except it would fail when the file hadn’t had a commit yet. This is annoying when writing the first version of a file, and I frequently forgot about this edge case until finally deciding to fix it.


I have been using Asciidoctor for processing AsciiDoc-formatted documents for a few years now. It splits the difference for me between the ubiquitous but forrest of variations that is Markdown and more rigorous formats like reStructuredText. I wanted the ability to export to HTML with no extra work to look good on a mobile device, and to render to PDF on occasion.

Whatever the tool, it must have solid support for LaTeX math (AMSmath), which has always been in my experience not as deep in Markdown tools as the layout looks like in my head — I frequently need more than a just a choice between inline and displaymath.

Perhaps I’ll (re)learn reStructuredText and see if Sphinx is a better match for more recent wants from my doc writing.

3. First solution

Search around for how to get the UNIX epoch timestamp from git and you get:

git log -1 --pretty=%ct

Ok, great! Modify ensure that the SOURCE_DATE_EPOCH environment variable is set when Asciidoctor runs and it will use that time instead of the current time. This is because the tool makes it relatively easy to do a reproducible build.

Automate this in a Makefile like so:

%.html: %.adoc
	SOURCE_DATE_EPOCH=$(shell git log -1 --pretty=%ct) asciidoctor ...

Perfect! Now we get a reader-visible indication of the last time the post was changed with no extra work to forget to do.

Do you see the bug?

It took me at least a year to notice that firing off a make all -B would result in updating all the timestamps. Re-rendering all of the pages is fine with me, if the particular .adoc didn’t change, the resulting HTML would be the same also with the properly set SOURCE_DATE_EPOCH.

I didn’t notice because I was mostly interested in the most recent file I’ve been editing and checking in a browser to ensure it renders as intended.

The bug is that the git log -1 --pretty=%ct returns the latest git commit date of the repository and not the last commit date of the particular file.

4. Second solution

Figuring out the nature of a problem is a long way down the road to fixing the issue. So, give the Git command the name of the current file that Make is considering at that moment.

SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct %.adoc)

But that last % doesn’t get replaced properly. Actually, I’ve forgotten exactly what the problem with this was, but it had to do with how Make interprets a Makefile.

The solution is to use a Makefile macro, which was a new thing for me.

Call the macro and give it a positional argument list. Pass that argument to the git command and done!

source_epoch macro solution
# Make macro/function
define source_epoch
SOURCE_DATE_EPOCH=$(shell git log -1 --pretty=%ct -- $(1))
endef

# ...

$(OUT)/%.html: $(SRC/%.adoc)
	$(call source_epoch, $<) \
	    $(ASCIIDOCTOR) \
	    $(ASCIIDOCTOR_OPTS) \
	    --out-file=$@ \
	    $<
  • The -- in the git command stops option processing and ensures the following argument is always interpreted as a file name. This was a gotcha that I’ve been gotcha’d before, so just good practice now.

  • $(call source_epoch, $<) uses the call function to give the first prerequisite ($<) as the first argument to the source_epoch macro, and leaves the result in its place.

Problem solved, plus learned more about functions in (GNU) Makefiles!

  1. but sometimes Asciidoctor would fail with a message like:

asciidoctor: FAILED: /home/dan/agnd-blog/src/adoc-last-updated/foo.adoc: Failed to load AsciiDoc document - invalid value for Integer(): ""
  Use --trace to show backtrace
make[1]: *** [Makefile:87: build/adoc-last-updated/foo.html] Error 1

Let. me. tell. you. Pretty sure I know about integers, that I wasn’t using any, and that this error message was not helpful. Not even adding the --trace option was useful.

A few experiments to get the offending .adoc to render and I would eventually remember that the error came from trying to use the value of the environment variable SOURCE_DATE_EPOCH but was set to an empty value. This because the git log would output nothing, BUT STILL RETURN a code of 0, which usually means “the program ran successfully.”

In some way, the program was successful, but I rely on a Make job failing whenever something internal returns nonzero to block continuing in the rendering or whatever else chain of commands; it’s kind of a feature.

So, now (re)recalling that the issue is with `git log’s behavior: add the file and make a commit, and everything works fine again.

Until it happens the next time and I repeat nearly the same troubleshooting again.

Time to fix this for good.

5. Final solution

What we know so far:

  • This only happens because the .adoc file in question hasn’t been introduced to the git repository.

  • I don’t want to need to remember to make a commit before rendering.

    • Sometimes I don’t even intend to commit this file, but still want to render it.

  • git log -1 — file.adoc will have an empty response if not in the repository.

  • If there is no commit for the file, then revert back to the behavior where Asciidoctor uses the current datetime.

    • But defining SOURCE_DATE_EPOCH disables that default behavior.

I’ve forgotten the five minutes of experimentation that yielded what’s below, so no more story.

Robust timestamp creation
# Make macro/function
define source_epoch
SOURCE_DATE_EPOCH=$(shell (git log -1 --pretty=%ct -- $(1); date +%s) | head -1)
endef

# ...

$(OUT)/%.html: $(SRC/%.adoc)
	$(call source_epoch, $<) \
	    $(ASCIIDOCTOR) \
	    $(ASCIIDOCTOR_OPTS) \
	    --out-file=$@ \
	    $<
  • Run two commands in a subshell.

  • Case 1: file has a commit

    • git log outputs the desired timestamp

    • date +%s outputs the current timestamp

    • Piping the ordered concatenation to head - leaves only the first

  • Case 2: no commit

    • git log outputs nothing

    • date +%s outputs the current UNIX time

    • head -1 passes the first (and only) line

Perhaps there is another edge case here, but it sure feels like the final solution.

I’m not rendering a bajillion source files, so the “inefficiency” of three commands, an extra subshell, and a pipe is negligible — my time is worth more than that saved in this application.

6. References