davideisinger.com

My personal website
Log | Files | Refs | README

check-dispatch-dates (3054B)


      1 #!/usr/bin/env ruby
      2 
      3 require "date"
      4 require "open3"
      5 require "shellwords"
      6 require "yaml"
      7 
      8 def git(*args)
      9   output, error, status = Open3.capture3("git", *args)
     10   raise "git #{args.first} failed: #{error.strip}" unless status.success?
     11   output
     12 end
     13 
     14 def frontmatter(revision, path)
     15   content = git("show", "#{revision}:#{path}")
     16   match = content.match(/\A---\r?\n(.*?)\r?\n---(?:\r?\n|\z)/m)
     17   raise "#{path}: missing YAML frontmatter" unless match
     18   data = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: false)
     19   raise "#{path}: frontmatter must be a mapping" unless data.is_a?(Hash)
     20   data
     21 end
     22 
     23 def publication_date(value)
     24   return value.to_datetime if value.respond_to?(:to_datetime)
     25   DateTime.iso8601(value.to_s)
     26 end
     27 
     28 begin
     29   # Only the publishing remote's main branch needs this guard.
     30   exit 0 unless ARGV[0] == "origin"
     31   now = DateTime.now
     32   problems = []
     33   STDIN.each_line do |line|
     34     local_ref, local_sha, remote_ref, remote_sha = line.split
     35     raise "Invalid pre-push input" unless [local_ref, local_sha, remote_ref, remote_sha].all?
     36     next unless remote_ref == "refs/heads/main"
     37     next if local_sha.match?(/\A0+\z/) # Branch deletion.
     38     if remote_sha.match?(/\A0+\z/)
     39       raise "origin/main does not exist yet; cannot distinguish new posts from an existing archive."
     40     end
     41     unless system("git", "cat-file", "-e", "#{remote_sha}^{commit}", out: File::NULL, err: File::NULL)
     42       raise "The current origin/main commit is unavailable locally. Run `git fetch origin` and retry."
     43     end
     44 
     45     changes = git("diff", "--name-status", "-z", "--find-renames", "--diff-filter=AMR",
     46                   remote_sha, local_sha, "--", "content/journal/").split("\0")
     47     until changes.empty?
     48       status = changes.shift
     49       old_path = changes.shift
     50       path = status.start_with?("R") ? changes.shift : old_path
     51       next unless path.match?(%r{\Acontent/journal/dispatch-[^/]+/index\.md\z})
     52 
     53       current = frontmatter(local_sha, path)
     54       next if current["draft"] == true
     55       unless status == "A"
     56         previous = frontmatter(remote_sha, old_path)
     57         next unless previous["draft"] == true
     58       end
     59 
     60       begin
     61         date = publication_date(current["date"])
     62         age_hours = (now - date) * 24
     63         next if age_hours <= 24
     64         reason = "publication date is #{age_hours.floor} hours old (maximum: 24 hours)"
     65       rescue ArgumentError, TypeError
     66         reason = "publication date is missing or invalid"
     67       end
     68       problems << "#{path}: #{reason}\n  Update it: bin/timestamp #{Shellwords.escape(path)}"
     69     end
     70   end
     71 
     72   unless problems.empty?
     73     warn "Push blocked: newly published Dispatches need a current timestamp.\n\n"
     74     warn problems.join("\n\n")
     75     warn "\nCommit the updated date, then push again. The check reads the commit being pushed, not uncommitted edits."
     76     warn "For intentional backdating, bypass once with `git push --no-verify origin main`."
     77     exit 1
     78   end
     79 rescue StandardError => error
     80   warn "Dispatch publication check failed: #{error.message}"
     81   exit 1
     82 end