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