diff --git a/.github/scripts/issue-lifecycle-preflight.rb b/.github/scripts/issue-lifecycle-preflight.rb index fce6979dd..98aaa0fa3 100755 --- a/.github/scripts/issue-lifecycle-preflight.rb +++ b/.github/scripts/issue-lifecycle-preflight.rb @@ -11,6 +11,10 @@ require "uri" EVIDENCE_EXCLUSIONS = ["GitHub metadata mutation", "branch protection and required checks", "deployment and runtime resources", "source code and Agent-led research PRD scope"].freeze +PREFLIGHT_SCHEMA = "monday.issue_lifecycle_preflight.v1" +MANIFEST_SCHEMA = "monday.issue_lifecycle_manifest.v1" +BUNDLE_FILES = %w[manifest.json manifest.json.sha256 preflight.json preflight.json.sha256].freeze +PAGE_KEYS = %w[api_version body_sha256 etag last_modified link media_type phase protocol request status].freeze module Canonical PRESERVE_ARRAY_ORDER = %w[comments commits events].freeze @@ -76,6 +80,34 @@ class GitHubReadOnly attr_reader :pages + def self.page_path(path, page) + separator = path.include?("?") ? "&" : "?" + "#{path}#{separator}per_page=100&page=#{page}" + end + + def self.link_relations(header) + return {} unless header + + header.split(",").each_with_object({}) do |part, relations| + match = part.match(/\A\s*<([^>]+)>;\s*rel="([^"]+)"\s*\z/) + raise "invalid pagination Link header" unless match + + match[2].split.each do |relation| + raise "duplicate pagination Link relation #{relation}" if relations.key?(relation) + + relations[relation] = api_path(match[1]) + end + end + end + + def self.api_path(url) + uri = URI(url) + raise "pagination link uses unexpected host #{uri.host.inspect}" unless uri.host == "api.github.com" + + path = uri.path.sub(%r{\A/}, "") + uri.query ? "#{path}?#{uri.query}" : path + end + def initialize(repo) unless repo.to_s.match?(/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/) raise "invalid repository #{repo.inspect}; expected OWNER/REPO" @@ -109,7 +141,7 @@ def get(path) end def paginate(path, collection_key: nil) - request_path = add_page(path, 1) + request_path = self.class.page_path(path, 1) seen_requests = {} seen_objects = {} items = [] @@ -138,11 +170,11 @@ def paginate(path, collection_key: nil) items << entry end - next_url = next_link(headers["link"]) - break if !next_url && (batch.length < 100 || expected_total == items.length) + next_path = self.class.link_relations(headers["link"])["next"] + break if !next_path && (batch.length < 100 || expected_total == items.length) page += 1 - request_path = next_url ? api_path(next_url) : add_page(path, page) + request_path = next_path || self.class.page_path(path, page) end if expected_total && items.length != expected_total @@ -229,7 +261,7 @@ def assert_rest_unchanged!(pages) unless status == 304 && headers["etag"] == etag raise "capture stability drift: endpoint/header #{page["request"]} changed during readback" end - @pages << page.merge("phase" => @phase, "status" => status, "etag" => headers["etag"], "last_modified" => headers["last-modified"], "api_version" => headers["x-github-api-version-selected"], "media_type" => headers["x-github-media-type"]) + @pages << page.merge("phase" => @phase, "status" => status, "etag" => headers["etag"], "last_modified" => headers["last-modified"], "link" => headers["link"], "api_version" => headers["x-github-api-version-selected"], "media_type" => headers["x-github-media-type"]) end end @@ -276,35 +308,13 @@ def record_page(protocol, request_name, body, headers, status) "status" => status, "etag" => headers["etag"], "last_modified" => headers["last-modified"], + "link" => headers["link"], "api_version" => headers["x-github-api-version-selected"], "media_type" => headers["x-github-media-type"], "body_sha256" => Digest::SHA256.hexdigest(Canonical.dump(body)) } end - def add_page(path, page) - separator = path.include?("?") ? "&" : "?" - "#{path}#{separator}per_page=100&page=#{page}" - end - - def next_link(header) - return unless header - - header.split(",").each do |part| - match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/) - return match[1] if match && match[2].split.include?("next") - end - nil - end - - def api_path(url) - uri = URI(url) - raise "pagination link uses unexpected host #{uri.host.inspect}" unless uri.host == "api.github.com" - - path = uri.path.sub(%r{\A/}, "") - uri.query ? "#{path}?#{uri.query}" : path - end - def object_identity(entry) return unless entry.is_a?(Hash) @@ -393,7 +403,7 @@ def read_graph(github, repo) end graph = { - "schema" => "monday.issue_lifecycle_preflight.v1", + "schema" => PREFLIGHT_SCHEMA, "repository" => repository, "label_catalog" => labels, "items" => items @@ -481,6 +491,358 @@ def write_bundle(output, graph, manifest) end end +def read_canonical_json(path) + contents = File.binread(path) + object = JSON.parse(contents) + raise "#{File.basename(path)} is not canonical JSON" unless contents.b == Canonical.dump(object).b + + object +rescue JSON::ParserError => error + raise "#{File.basename(path)} is invalid JSON: #{error.message}" +end + +def verify_sidecar(bundle, filename) + sidecar = "#{filename}.sha256" + contents = File.binread(File.join(bundle, sidecar)) + match = contents.match(/\A([0-9a-f]{64}) #{Regexp.escape(filename)}\n\z/) + raise "#{sidecar} is invalid" unless match + + actual = Digest::SHA256.file(File.join(bundle, filename)).hexdigest + raise "#{filename} digest mismatch" unless actual == match[1] + + actual +end + +def collection_scope(request, repo) + path, query = request.split("?", 2) + relative = path[%r{\Arepos/#{Regexp.escape(repo)}/(.+)\z}, 1] || path[%r{\Arepositories/[1-9][0-9]*/(.+)\z}, 1] + return unless relative + + filters = URI.decode_www_form(query.to_s).reject { |key, _| %w[after before page].include?(key) }.sort + [relative, filters] +rescue ArgumentError + nil +end + +def verify_page_inventory!(pages, graphql_media_type, graph, repo, link_provenance:) + raise "manifest page inventory is empty" unless pages.is_a?(Array) && !pages.empty? + + pages.each do |page| + unless page.is_a?(Hash) && page.keys.sort == PAGE_KEYS && + %w[capture stability_check].include?(page["phase"]) && + %w[graphql rest].include?(page["protocol"]) && + !page["request"].to_s.empty? && page["body_sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) && + (page["link"].nil? || page["link"].is_a?(String)) + raise "manifest page inventory entry is invalid" + end + expected_status = page["phase"] == "stability_check" && page["protocol"] == "rest" ? 304 : 200..299 + raise "manifest page status is invalid" unless expected_status === page["status"] + end + + endpoint_set = lambda do |phase| + pages.select { |page| page["phase"] == phase }.map { |page| [page["protocol"], page["request"]] } + end + captured = endpoint_set.call("capture") + stable = endpoint_set.call("stability_check") + unless captured.any? && captured.uniq.length == captured.length && stable.uniq.length == stable.length && captured.sort == stable.sort + raise "manifest page inventory phases do not match" + end + captured_pages = pages.select { |page| page["phase"] == "capture" }.to_h { |page| [[page["protocol"], page["request"]], page] } + pages.select { |page| page["phase"] == "stability_check" }.each do |page| + original = captured_pages.fetch([page["protocol"], page["request"]]) + raise "manifest page body identity changed" unless page["body_sha256"] == original["body_sha256"] + next unless page["protocol"] == "rest" + + if original["etag"].to_s.empty? || page["etag"] != original["etag"] + raise "manifest REST ETag provenance is invalid" + end + end + + graphql_media_types = pages.select { |page| page["protocol"] == "graphql" }.map { |page| page["media_type"] } + unless graphql_media_types.any? && graphql_media_types.all? { |media_type| !media_type.to_s.empty? } && graphql_media_types.uniq == [graphql_media_type] + raise "manifest GraphQL media type does not match page inventory" + end + rest_pages = pages.select { |page| page["phase"] == "capture" && page["protocol"] == "rest" } + unless rest_pages.any? && rest_pages.all? { |page| page["api_version"] == GitHubReadOnly::API_VERSION && !page["media_type"].to_s.empty? } + raise "manifest REST provenance is incomplete" + end + + collections = [] + paginated = lambda do |path, count, known_total = false| + page_count = known_total ? [1, (count + 99) / 100].max : count / 100 + 1 + start = GitHubReadOnly.page_path(path, 1) + collections << [path, start, page_count, collection_scope(start, repo)] + end + items = graph.fetch("items") + expected_rest = [] + paginated.call("repos/#{repo}/labels", graph.fetch("label_catalog").length) + paginated.call("repos/#{repo}/issues?state=all&sort=created&direction=asc", items.length) + paginated.call("repos/#{repo}/issues/comments?sort=created&direction=asc", items.sum { |item| item.fetch("comments").length }) + paginated.call("repos/#{repo}/issues/events", items.sum { |item| item.fetch("events").length }) + items.each do |item| + number = item.fetch("number") + expected_rest << "repos/#{repo}/issues/#{number}" + next unless item.fetch("kind") == "pull_request" + + pull_request = item.fetch("pull_request") + head_sha = pull_request.dig("metadata", "head", "sha") + expected_rest << "repos/#{repo}/pulls/#{number}" + %w[commits files reviews].each do |field| + paginated.call("repos/#{repo}/pulls/#{number}/#{field}", pull_request.fetch(field).length) + end + paginated.call("repos/#{repo}/pulls/#{number}/comments", pull_request.fetch("review_comments").length) + paginated.call("repos/#{repo}/commits/#{head_sha}/check-runs?filter=all", pull_request.dig("check_runs", "total_count"), true) + paginated.call("repos/#{repo}/commits/#{head_sha}/statuses", pull_request.fetch("statuses").length) + end + rest_by_request = rest_pages.to_h { |page| [page.fetch("request"), page] } + collection_requests = collections.flat_map do |path, start, page_count, scope| + if link_provenance + current = start + (0...page_count).map do |index| + page = rest_by_request.fetch(current) { raise "manifest REST pagination chain is incomplete" } + relations = GitHubReadOnly.link_relations(page["link"]) + raise "manifest REST Link scope is invalid" unless relations.values.all? { |target| collection_scope(target, repo) == scope } + + if index + 1 == page_count + raise "manifest REST pagination chain has an extra page" if relations["next"] + else + current = relations["next"] || GitHubReadOnly.page_path(path, index + 2) + end + page.fetch("request") + end + else + raise "legacy manifest cannot verify multi-page REST provenance" if page_count > 1 + requests = rest_pages.each_with_object([]) do |page, result| + result << page.fetch("request") if collection_scope(page.fetch("request"), repo) == scope + end + raise "legacy manifest REST page inventory does not match preflight" unless requests == [start] + requests + end + end + raise "manifest REST page inventory overlaps collections" unless collection_requests.uniq.length == collection_requests.length + expected_rest.concat(collection_requests) + actual_rest = rest_pages.map { |page| page.fetch("request") } + raise "manifest REST page inventory does not match preflight" unless actual_rest.sort == expected_rest.sort + + if link_provenance + rest_pages.reject { |page| collection_requests.include?(page.fetch("request")) }.each do |page| + raise "manifest detail page unexpectedly has Link provenance" if page["link"] + end + end + + graphql_requests = pages.select { |page| page["phase"] == "capture" && page["protocol"] == "graphql" }.map { |page| page.fetch("request") } + issue_count = items.count { |item| item.fetch("kind") == "issue" } + graphql_page_count = [1, (issue_count + 49) / 50].max + graphql_prefix = "IssueLifecycleRelationships:" + unless graphql_requests.length == graphql_page_count && graphql_requests.count("#{graphql_prefix}START") == 1 && + graphql_requests.all? { |request| request.start_with?(graphql_prefix) && request.length > graphql_prefix.length } + raise "manifest GraphQL page inventory does not match preflight" + end +end + +def relationship_reference?(reference, kind) + return false unless reference.is_a?(Hash) && reference.keys.sort == %w[number repository url] && + reference["number"].is_a?(Integer) && reference["number"].positive? && reference["repository"].is_a?(Hash) && + reference["repository"].keys == ["nameWithOwner"] + repo = reference.dig("repository", "nameWithOwner") + url = URI(reference["url"].to_s) + repo.to_s.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}) && url.is_a?(URI::HTTPS) && url.host == "github.com" && + url.port == 443 && !url.userinfo && !url.query && !url.fragment && url.path == "/#{repo}/#{kind}/#{reference["number"]}" +rescue URI::InvalidURIError + false +end + +def unique_identity_fields?(entries, *fields) + return false unless entries.is_a?(Array) && entries.all? { |entry| entry.is_a?(Hash) } + + fields.all? do |field| + identities = entries.map { |entry| entry[field] } + identities.all? { |identity| identity && !identity.to_s.empty? } && identities.uniq.length == identities.length + end +end + +def api_url_path?(url, expected_path) + uri = URI(url.to_s) + uri.is_a?(URI::HTTPS) && uri.host == "api.github.com" && uri.port == 443 && !uri.userinfo && !uri.query && !uri.fragment && uri.path == expected_path +rescue URI::InvalidURIError + false +end + +def issue_object_scope?(object, repo, number) + url = object["issue_url"] || object.dig("issue", "url") + api_url_path?(url, "/repos/#{repo}/issues/#{number}") +end + +def pull_request_object_scope?(object, repo, number) + url = object["pull_request_url"] || object.dig("_links", "pull_request", "href") + api_url_path?(url, "/repos/#{repo}/pulls/#{number}") +end + +def validate_graph_and_count(graph, repo) + unless graph.is_a?(Hash) && graph.keys.sort == %w[items label_catalog repository schema] && graph["schema"] == PREFLIGHT_SCHEMA + raise "preflight schema is invalid" + end + repository = graph.fetch("repository") + unless repository.is_a?(Hash) && repository.keys.sort == %w[default_branch default_branch_sha full_name id] && + repository["full_name"] == repo && !repository["id"].to_s.empty? && + !repository["default_branch"].to_s.empty? && repository["default_branch_sha"].to_s.match?(/\A[0-9a-f]{40}\z/i) + raise "preflight repository identity is invalid" + end + + labels = graph.fetch("label_catalog") + items = graph.fetch("items") + raise "preflight collections are invalid" unless labels.is_a?(Array) && items.is_a?(Array) + unless labels.all? { |label| label.is_a?(Hash) && label["id"].is_a?(Integer) && !label["name"].to_s.empty? } && + labels.map { |label| label["id"] }.uniq.length == labels.length && + labels.map { |label| label["name"] }.uniq.length == labels.length + raise "preflight label catalog is invalid" + end + label_catalog = labels.to_h { |label| [label.fetch("id"), label.fetch("name")] } + + numbers = [] + issue_count = 0 + pull_request_count = 0 + comment_count = 0 + event_count = 0 + comment_ids = [] + event_ids = [] + items.each do |item| + raise "preflight item is invalid" unless item.is_a?(Hash) && item["number"].is_a?(Integer) && item["number"].positive? + + number = item["number"] + metadata = item["issue"] + comments = item["comments"] + events = item["events"] + unless metadata.is_a?(Hash) && metadata["number"] == number && metadata.key?("body") && + api_url_path?(metadata["url"], "/repos/#{repo}/issues/#{number}") && + %w[open closed].include?(metadata["state"]) && metadata["labels"].is_a?(Array) && + metadata["assignees"].is_a?(Array) && metadata["comments"] == comments&.length && + unique_identity_fields?(metadata["labels"], "id", "name") && + metadata["labels"].all? { |label| label_catalog[label["id"]] == label["name"] } && + unique_identity_fields?(metadata["assignees"], "id", "login") && + unique_identity_fields?(comments, "id") && comments.all? { |comment| issue_object_scope?(comment, repo, number) } && + unique_identity_fields?(events, "id") && events.all? { |event| issue_object_scope?(event, repo, number) } + raise "preflight item ##{number} metadata is invalid" + end + case item["kind"] + when "issue" + relationships = item["relationships"] + relationship_lists = { "blocked_by" => "issues", "blocking" => "issues", "closed_by_pull_requests" => "pull", "sub_issues" => "issues" } + unless item.keys.sort == %w[comments events issue kind number relationships] && + relationships.is_a?(Hash) && + relationships.keys.sort == %w[blocked_by blocking closed_by_pull_requests number parent sub_issues] && + relationships["number"] == number && + (!relationships["parent"] || relationship_reference?(relationships["parent"], "issues")) && + relationship_lists.all? do |key, kind| + relationships[key].is_a?(Array) && relationships[key].all? { |reference| relationship_reference?(reference, kind) } + end + raise "preflight Issue ##{number} schema is invalid" + end + + issue_count += 1 + when "pull_request" + pull_request = item["pull_request"] + unless item.keys.sort == %w[comments events issue kind number pull_request] && pull_request.is_a?(Hash) && + pull_request.keys.sort == %w[check_runs commits files metadata review_comments reviews statuses] && + pull_request["metadata"].is_a?(Hash) && pull_request.dig("metadata", "number") == number && + api_url_path?(pull_request.dig("metadata", "url"), "/repos/#{repo}/pulls/#{number}") && + pull_request.dig("metadata", "head", "sha").to_s.match?(/\A[0-9a-f]{40}\z/i) && + pull_request.dig("metadata", "base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/i) && + unique_identity_fields?(pull_request["commits"], "sha") && pull_request["commits"].all? { |commit| commit["sha"].match?(/\A[0-9a-f]{40}\z/i) } && + pull_request["commits"].any? { |commit| commit["sha"] == pull_request.dig("metadata", "head", "sha") } && + unique_identity_fields?(pull_request["files"], "filename") && + unique_identity_fields?(pull_request["reviews"], "id") && pull_request["reviews"].all? { |review| pull_request_object_scope?(review, repo, number) } && + unique_identity_fields?(pull_request["review_comments"], "id") && pull_request["review_comments"].all? { |comment| pull_request_object_scope?(comment, repo, number) } && + unique_identity_fields?(pull_request["statuses"], "id") && + pull_request["commits"].length == pull_request.dig("metadata", "commits") && + pull_request["files"].length == pull_request.dig("metadata", "changed_files") && + pull_request["review_comments"].length == pull_request.dig("metadata", "review_comments") && + pull_request["check_runs"].is_a?(Hash) && pull_request["check_runs"].keys.sort == %w[check_runs total_count] && + unique_identity_fields?(pull_request.dig("check_runs", "check_runs"), "id") && + pull_request.dig("check_runs", "check_runs").all? { |run| run["head_sha"] == pull_request.dig("metadata", "head", "sha") } && + pull_request.dig("check_runs", "total_count") == pull_request.dig("check_runs", "check_runs").length + raise "preflight PR ##{number} schema is invalid" + end + + pull_request_count += 1 + else + raise "preflight item ##{number} kind is invalid" + end + numbers << number + comment_ids.concat(comments.map { |comment| comment.fetch("id") }) + event_ids.concat(events.map { |event| event.fetch("id") }) + comment_count += comments.length + event_count += events.length + end + raise "preflight contains duplicate item numbers" unless numbers.uniq.length == numbers.length + raise "preflight contains duplicate comment or event identities" unless comment_ids.uniq.length == comment_ids.length && event_ids.uniq.length == event_ids.length + + { + "issues" => issue_count, + "pull_requests" => pull_request_count, + "labels" => labels.length, + "issue_comments" => comment_count, + "issue_events" => event_count + } +end + +def verify_bundle(bundle, repo, controller) + unless File.directory?(bundle) && !File.symlink?(bundle) && Dir.children(bundle).sort == BUNDLE_FILES + raise "bundle file set is invalid" + end + BUNDLE_FILES.each do |filename| + path = File.join(bundle, filename) + raise "bundle entry #{filename} is not a regular file" unless File.file?(path) && !File.symlink?(path) + end + + preflight_sha = verify_sidecar(bundle, "preflight.json") + verify_sidecar(bundle, "manifest.json") + graph = read_canonical_json(File.join(bundle, "preflight.json")) + manifest = read_canonical_json(File.join(bundle, "manifest.json")) + expected_manifest_keys = %w[api captured_at controller counts default_branch default_branch_sha exclusions pages preflight repository schema target] + unless manifest.is_a?(Hash) && manifest.keys.sort == expected_manifest_keys && manifest["schema"] == MANIFEST_SCHEMA + raise "manifest schema is invalid" + end + unless manifest["repository"] == repo && manifest["controller"] == controller && + manifest["target"] == "GitHub Issue and pull request metadata in #{repo}" && manifest["exclusions"] == EVIDENCE_EXCLUSIONS + raise "manifest scope is invalid" + end + begin + captured_at = manifest.fetch("captured_at") + raise ArgumentError unless captured_at.is_a?(String) && captured_at.end_with?("Z") && Time.iso8601(captured_at).utc_offset.zero? + rescue ArgumentError + raise "manifest capture time is not UTC" + end + + api = manifest.fetch("api") + unless api.is_a?(Hash) && api.keys.sort == %w[graphql_media_type rest_accept rest_version] && + api["rest_version"] == GitHubReadOnly::API_VERSION && api["rest_accept"] == GitHubReadOnly::REST_ACCEPT && + !api["graphql_media_type"].to_s.empty? + raise "manifest API provenance is invalid" + end + pages = manifest.fetch("pages") + link_provenance = pages.is_a?(Array) && pages.all? { |page| page.is_a?(Hash) && page.key?("link") } + if pages.is_a?(Array) + has_link = pages.any? { |page| page.is_a?(Hash) && page.key?("link") } + raise "manifest page Link provenance is inconsistent" if has_link && !link_provenance + + manifest["pages"] = pages.map { |page| page.is_a?(Hash) && !page.key?("link") ? page.merge("link" => nil) : page } + end + counts = validate_graph_and_count(graph, repo) + verify_page_inventory!(manifest.fetch("pages"), api.fetch("graphql_media_type"), graph, repo, link_provenance: link_provenance) + raise "manifest counts do not match preflight" unless manifest["counts"] == counts + + repository = graph.fetch("repository") + unless manifest["default_branch"] == repository["default_branch"] && + manifest["default_branch_sha"] == repository["default_branch_sha"] && + manifest["preflight"] == { "file" => "preflight.json", "sha256" => preflight_sha } + raise "manifest preflight identity is invalid" + end + [graph, manifest, link_provenance] +rescue Errno::EACCES, Errno::ENOENT => error + raise "bundle read failed: #{error.message}" +end + def capture(options) repo, controller, output = options.values_at(:repo, :controller, :output) raise "--repo OWNER/REPO is required" if repo.to_s.empty? @@ -493,7 +855,7 @@ def capture(options) raise "capture stability drift: GraphQL media type missing or inconsistent: #{graphql_media_types.uniq.inspect}" end manifest = { - "schema" => "monday.issue_lifecycle_manifest.v1", + "schema" => MANIFEST_SCHEMA, "repository" => repo, "captured_at" => Time.now.utc.iso8601(6), "controller" => controller, @@ -513,21 +875,57 @@ def capture(options) puts "captured #{repo} at #{default_branch_sha} -> #{output}" end +def verify(options) + repo, controller, bundle = options.values_at(:repo, :controller, :bundle) + raise "--repo OWNER/REPO is required" if repo.to_s.empty? + raise "--controller NAME is required" if controller.to_s.strip.empty? + raise "--bundle DIR is required" if bundle.to_s.empty? + + graph, manifest, link_provenance = verify_bundle(bundle, repo, controller) + if options[:live] + live_graph, live_counts, live_branch, live_branch_sha, live_pages = capture_graph(repo) + verify_page_inventory!(live_pages, manifest.dig("api", "graphql_media_type"), live_graph, repo, link_provenance: true) + raise "live verification drift: Issue/PR graph changed" unless Canonical.value(live_graph) == Canonical.value(graph) + raise "live verification drift: counts changed" unless live_counts == manifest["counts"] + unless live_branch == manifest["default_branch"] && live_branch_sha == manifest["default_branch_sha"] + raise "live verification drift: default branch changed" + end + comparison_pages = link_provenance ? live_pages : live_pages.map { |page| page.merge("link" => nil) } + unless Canonical.value(comparison_pages) == Canonical.value(manifest["pages"]) + raise "live verification drift: page/header inventory changed" + end + end + suffix = options[:live] ? " with live readback" : "" + puts "verified #{repo} at #{manifest.fetch("default_branch_sha")}#{suffix} <- #{bundle}" +end + command = ARGV.shift options = {} parser = OptionParser.new do |flags| - flags.banner = "Usage: issue-lifecycle-preflight.rb capture --repo OWNER/REPO --controller NAME --output DIR" + flags.banner = "Usage: issue-lifecycle-preflight.rb (capture|verify) --repo OWNER/REPO --controller NAME (--output|--bundle) DIR" flags.on("--repo OWNER/REPO", "Repository to read") { |value| options[:repo] = value } flags.on("--controller NAME", "Named evidence controller") { |value| options[:controller] = value } flags.on("--output DIR", "New evidence bundle directory") { |value| options[:output] = value } + flags.on("--bundle DIR", "Existing evidence bundle directory") { |value| options[:bundle] = value } + flags.on("--live", "Independently compare the bundle with live GitHub reads") { options[:live] = true } end begin parser.parse! raise "unexpected arguments: #{ARGV.join(" ")}" unless ARGV.empty? - raise "unsupported operation #{command.inspect}; expected capture" unless command == "capture" - - capture(options) + case command + when "capture" + raise "--bundle is only valid for verify" if options[:bundle] + raise "--live is only valid for verify" if options[:live] + + capture(options) + when "verify" + raise "--output is only valid for capture" if options[:output] + + verify(options) + else + raise "unsupported operation #{command.inspect}; expected capture or verify" + end rescue StandardError => error warn "ERROR issue lifecycle preflight: #{error.message}" exit 2 diff --git a/.github/scripts/test-issue-lifecycle-preflight.sh b/.github/scripts/test-issue-lifecycle-preflight.sh index 707afd33d..8e6474f02 100755 --- a/.github/scripts/test-issue-lifecycle-preflight.sh +++ b/.github/scripts/test-issue-lifecycle-preflight.sh @@ -24,7 +24,7 @@ for ((index = 0; index < ${#args[@]}; index++)); do graphql) path=graphql; method=POST ;; If-None-Match:*) if_none_match="${args[$index]#If-None-Match: }" ;; query=*) query="${args[$index]#query=}" ;; - repos/*) path="${args[$index]}" ;; + repos/*|repositories/*) path="${args[$index]}" ;; esac done operation="${query%%[[:space:]]*}" @@ -46,16 +46,22 @@ respond() { printf 'HTTP/2.0 304 Not Modified\r\nEtag: W/"%s"\r\n\r\n' "$etag"; exit 1 fi if [[ "$path" == graphql && "${FIXTURE_MODE:-normal}" != missing-relationship ]]; then - body="${body//\{\"number\":3\}/\{\"number\":3,\"url\":\"https:\/\/github.test\/example\/repo\/issues\/3\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" - body="${body//\{\"number\":1\}/\{\"number\":1,\"url\":\"https:\/\/github.test\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" - body="${body//\"number\":3,\"parent\":null/\"number\":3,\"parent\":\{\"number\":1,\"url\":\"https:\/\/github.test\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" + body="${body//\{\"number\":3\}/\{\"number\":3,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/3\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" + body="${body//\{\"number\":1\}/\{\"number\":1,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" + body="${body//\"number\":3,\"parent\":null/\"number\":3,\"parent\":\{\"number\":1,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" fi - local media_header= + local media_header= link_header= [[ -z "$media_type" ]] || media_header="X-GitHub-Media-Type: ${media_type}"$'\r\n' - printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nEtag: W/"%s"\r\nLast-Modified: Sat, 02 Aug 2026 00:00:00 GMT\r\nX-GitHub-Api-Version-Selected: 2026-03-10\r\n%s\r\n%s\n' "$etag" "$media_header" "$body" + if [[ "${FIXTURE_MODE:-normal}" == live-link-drift && "$path" == repos/example/repo/labels* ]]; then + link_header=$'Link: ; rel="prev"\r\n' + fi + printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nEtag: W/"%s"\r\nLast-Modified: Sat, 02 Aug 2026 00:00:00 GMT\r\nX-GitHub-Api-Version-Selected: 2026-03-10\r\n%s%s\r\n%s\n' "$etag" "$media_header" "$link_header" "$body" } respond_with_next() { + if [[ -n "$if_none_match" && "$if_none_match" == 'W/"fixture"' ]]; then + printf 'HTTP/2.0 304 Not Modified\r\nEtag: W/"fixture"\r\n\r\n'; exit 1 + fi printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nEtag: W/"fixture"\r\nLast-Modified: Sat, 02 Aug 2026 00:00:00 GMT\r\nLink: <%s>; rel="next"\r\nX-GitHub-Api-Version-Selected: 2026-03-10\r\nX-GitHub-Media-Type: github.v3; format=json\r\n\r\n%s\n' "$1" "$2" } @@ -68,7 +74,10 @@ fi case "$path" in repos/example/repo/labels*) - if [[ "${FIXTURE_MODE:-normal}" == missing-link ]]; then + if [[ "${FIXTURE_MODE:-normal}" == canonical-link ]]; then + body="$(ruby -rjson -e 'puts JSON.generate([{"id" => 10, "name" => "enhancement"}, {"id" => 11, "name" => "ready-for-agent"}] + (100..197).map { |id| {"id" => id, "name" => "label-#{id}"} })')" + respond_with_next 'https://api.github.com/repositories/123456/labels?per_page=100&page=2&after=cursor' "$body" + elif [[ "${FIXTURE_MODE:-normal}" == missing-link ]]; then if [[ "$path" == *"&page=1"* ]]; then respond "$(ruby -rjson -e 'puts JSON.generate(100.times.map { |i| {"id" => i, "name" => "label-#{i}"} })')" else @@ -87,34 +96,37 @@ case "$path" in respond "$labels" fi ;; + repositories/123456/labels*) + respond '[{"id":198,"name":"label-198"}]' + ;; repos/example/repo/issues\?state=all*) - respond '[{"id":101,"number":1},{"id":103,"number":3},{"id":102,"number":2,"pull_request":{"url":"https://api.github.test/repos/example/repo/pulls/2"}}]' + respond '[{"id":101,"number":1},{"id":103,"number":3},{"id":102,"number":2,"pull_request":{"url":"https://api.github.com/repos/example/repo/pulls/2"}}]' ;; repos/example/repo/issues/comments*) call="$(bump comments)" - comments='[{"id":1001,"issue_url":"https://api.github.test/repos/example/repo/issues/1","body":"evidence"},{"id":1002,"issue_url":"https://api.github.test/repos/example/repo/issues/2","body":"review context"}]' - if [[ "${FIXTURE_MODE:-normal}" == capture-race && "$call" -gt 1 ]]; then - comments='[{"id":1001,"issue_url":"https://api.github.test/repos/example/repo/issues/1","body":"evidence changed concurrently"},{"id":1002,"issue_url":"https://api.github.test/repos/example/repo/issues/2","body":"review context"}]' + comments='[{"id":1001,"issue_url":"https://api.github.com/repos/example/repo/issues/1","body":"evidence"},{"id":1002,"issue_url":"https://api.github.com/repos/example/repo/issues/2","body":"review context"}]' + if [[ "${FIXTURE_MODE:-normal}" == live-drift || "${FIXTURE_MODE:-normal}" == capture-race && "$call" -gt 1 ]]; then + comments='[{"id":1001,"issue_url":"https://api.github.com/repos/example/repo/issues/1","body":"evidence changed concurrently"},{"id":1002,"issue_url":"https://api.github.com/repos/example/repo/issues/2","body":"review context"}]' fi etag=fixture [[ "$call" -le 1 || "${FIXTURE_MODE:-normal}" != header-race && "${FIXTURE_MODE:-normal}" != capture-race ]] || etag=changed respond "$comments" "$etag" ;; repos/example/repo/issues/events*) - respond '[{"id":2001,"event":"labeled","issue":{"number":1}},{"id":2002,"event":"cross-referenced","issue":{"number":2}}]' + respond '[{"id":2001,"event":"labeled","issue":{"number":1,"url":"https://api.github.com/repos/example/repo/issues/1"}},{"id":2002,"event":"cross-referenced","issue":{"number":2,"url":"https://api.github.com/repos/example/repo/issues/2"}}]' ;; repos/example/repo/issues/1) - respond "{\"id\":101,\"node_id\":\"I_one\",\"number\":1,\"comments\":1,\"state\":\"open\",\"body\":\"Issue one\",\"labels\":$labels,\"assignees\":$assignees,\"updated_at\":\"2026-08-02T00:00:00Z\"}" + respond "{\"id\":101,\"node_id\":\"I_one\",\"url\":\"https://api.github.com/repos/example/repo/issues/1\",\"number\":1,\"comments\":1,\"state\":\"open\",\"body\":\"Issue one 研究\",\"labels\":$labels,\"assignees\":$assignees,\"updated_at\":\"2026-08-02T00:00:00Z\"}" ;; repos/example/repo/issues/3) call="$(bump issue3)" [[ "$call" -le 1 || -n "$if_none_match" ]] || { echo "stability GET missing If-None-Match" >&2; exit 1; } body=Blocker etag=fixture [[ "${FIXTURE_MODE:-normal}" != issue-detail-race || "$call" -le 1 ]] || { body='Blocker changed concurrently'; etag=changed; } - respond "{\"id\":103,\"node_id\":\"I_three\",\"number\":3,\"comments\":0,\"state\":\"closed\",\"body\":\"$body\",\"labels\":[],\"assignees\":[],\"updated_at\":\"2026-08-01T00:00:00Z\"}" "$etag" + respond "{\"id\":103,\"node_id\":\"I_three\",\"url\":\"https://api.github.com/repos/example/repo/issues/3\",\"number\":3,\"comments\":0,\"state\":\"closed\",\"body\":\"$body\",\"labels\":[],\"assignees\":[],\"updated_at\":\"2026-08-01T00:00:00Z\"}" "$etag" ;; repos/example/repo/issues/2) - respond "{\"id\":102,\"node_id\":\"PR_two\",\"number\":2,\"comments\":1,\"state\":\"open\",\"body\":\"Pull request conversation\",\"labels\":$labels,\"assignees\":[],\"pull_request\":{\"url\":\"https://api.github.test/repos/example/repo/pulls/2\"},\"updated_at\":\"2026-08-02T00:00:00Z\"}" + respond "{\"id\":102,\"node_id\":\"PR_two\",\"url\":\"https://api.github.com/repos/example/repo/issues/2\",\"number\":2,\"comments\":1,\"state\":\"open\",\"body\":\"Pull request conversation\",\"labels\":$labels,\"assignees\":[],\"pull_request\":{\"url\":\"https://api.github.com/repos/example/repo/pulls/2\"},\"updated_at\":\"2026-08-02T00:00:00Z\"}" ;; repos/example/repo/pulls/2) call="$(bump pull2)" @@ -122,14 +134,14 @@ case "$path" in if [[ "${FIXTURE_MODE:-normal}" == closed-pr-race ]]; then state=closed; [[ "$call" -le 1 ]] || { title='Changed concurrently'; etag=changed; } fi - respond "{\"id\":202,\"node_id\":\"PR_two\",\"number\":2,\"state\":\"$state\",\"title\":\"$title\",\"head\":{\"ref\":\"feature\",\"sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"base\":{\"ref\":\"main\",\"sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"commits\":1,\"changed_files\":2,\"review_comments\":1}" "${etag:-fixture}" + respond "{\"id\":202,\"node_id\":\"PR_two\",\"url\":\"https://api.github.com/repos/example/repo/pulls/2\",\"number\":2,\"state\":\"$state\",\"title\":\"$title\",\"head\":{\"ref\":\"feature\",\"sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"base\":{\"ref\":\"main\",\"sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"commits\":1,\"changed_files\":2,\"review_comments\":1}" "${etag:-fixture}" ;; repos/example/repo/pulls/2/commits*) respond '[{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","commit":{"message":"Refs #1"}}]' ;; repos/example/repo/pulls/2/files*) respond '[{"sha":"cccccccccccccccccccccccccccccccccccccccc","filename":"safe-copy.txt","status":"added"},{"sha":"cccccccccccccccccccccccccccccccccccccccc","filename":"safe.txt","status":"added"}]' ;; - repos/example/repo/pulls/2/reviews*) respond '[{"id":3001,"node_id":"PRR_review","state":"APPROVED"}]' ;; - repos/example/repo/pulls/2/comments*) respond '[{"id":4001,"node_id":"PRRC_comment","body":"looks good"}]' ;; + repos/example/repo/pulls/2/reviews*) respond '[{"id":3001,"node_id":"PRR_review","state":"APPROVED","_links":{"pull_request":{"href":"https://api.github.com/repos/example/repo/pulls/2"}}}]' ;; + repos/example/repo/pulls/2/comments*) respond '[{"id":4001,"node_id":"PRRC_comment","pull_request_url":"https://api.github.com/repos/example/repo/pulls/2","body":"looks good"}]' ;; repos/example/repo/commits/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/check-runs*) - respond '{"total_count":1,"check_runs":[{"id":5001,"node_id":"CR_check","name":"CI","status":"completed","conclusion":"success"}]}' + respond '{"total_count":1,"check_runs":[{"id":5001,"node_id":"CR_check","head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","name":"CI","status":"completed","conclusion":"success"}]}' ;; repos/example/repo/commits/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/statuses*) respond '[{"id":6001,"node_id":"SC_status","context":"legacy","state":"success"}]' @@ -141,9 +153,9 @@ case "$path" in [[ "${FIXTURE_MODE:-normal}" != graphql-media-race || "$call" -le 1 ]] || media_type='github.v4; format=json; drift=1' [[ "${FIXTURE_MODE:-normal}" != graphql-media-missing || "$call" -le 1 ]] || media_type= if [[ "${FIXTURE_MODE:-normal}" == reordered ]]; then - respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"nodes":[{"number":3,"parent":null,"subIssues":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":1,"nodes":[{"number":1}],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}}},{"number":1,"parent":null,"subIssues":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":1,"nodes":[{"number":3}],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":1,"nodes":[{"number":2,"url":"https://github.test/example/repo/pull/2","repository":{"nameWithOwner":"example/repo"}}],"pageInfo":{"hasNextPage":false}}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' fixture "$media_type" + respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"nodes":[{"number":3,"parent":null,"subIssues":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":1,"nodes":[{"number":1}],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}}},{"number":1,"parent":null,"subIssues":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":1,"nodes":[{"number":3}],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":1,"nodes":[{"number":2,"url":"https://github.com/example/repo/pull/2","repository":{"nameWithOwner":"example/repo"}}],"pageInfo":{"hasNextPage":false}}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' fixture "$media_type" else - respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"pageInfo":{"endCursor":null,"hasNextPage":false},"nodes":[{"number":1,"parent":null,"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[{"number":3}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[{"repository":{"nameWithOwner":"example/repo"},"url":"https://github.test/example/repo/pull/2","number":2}],"totalCount":1,"pageInfo":{"hasNextPage":false}}},{"number":3,"parent":null,"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[{"number":1}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}}}]}}}}' fixture "$media_type" + respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"pageInfo":{"endCursor":null,"hasNextPage":false},"nodes":[{"number":1,"parent":null,"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[{"number":3}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[{"repository":{"nameWithOwner":"example/repo"},"url":"https://github.com/example/repo/pull/2","number":2}],"totalCount":1,"pageInfo":{"hasNextPage":false}}},{"number":3,"parent":null,"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[{"number":1}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}}}]}}}}' fixture "$media_type" fi ;; *) echo "unexpected GitHub API path: $path" >&2; exit 1 ;; @@ -160,9 +172,95 @@ capture() { --repo example/repo --controller "Codex /root" --output "$output" } +verify() { + local bundle="$1" + shift + PATH="$fake_bin:$PATH" ruby "$preflight" verify \ + --repo example/repo --controller "Codex /root" --bundle "$bundle" "$@" +} + +verify_live() { + local mode="$1" bundle="$2" + rm -f "$TEST_FAKE_STATE"/* + FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" verify \ + --repo example/repo --controller "Codex /root" --bundle "$bundle" --live +} + +copy_bundle() { + cp -R "$tmp_dir/bundle-a" "$tmp_dir/$1" +} + +rehash() { + local bundle="$1" filename="$2" + (cd "$bundle" && sha256sum "$filename" >"$filename.sha256") +} + +resign_preflight() { + local bundle="$1" + rehash "$bundle" preflight.json + ruby -rdigest -rjson -e ' + bundle = ARGV.fetch(0) + path = File.join(bundle, "manifest.json") + object = JSON.parse(File.binread(path)) + object.fetch("preflight")["sha256"] = Digest::SHA256.file(File.join(bundle, "preflight.json")).hexdigest + File.binwrite(path, JSON.generate(object) + "\n") + ' "$bundle" + rehash "$bundle" manifest.json +} + +set_json_field() { + local path="$1" key="$2" value="$3" + ruby -rjson -e ' + path, key, value = ARGV + object = JSON.parse(File.binread(path)) + object[key] = JSON.parse(value) + File.binwrite(path, JSON.generate(object) + "\n") + ' "$path" "$key" "$value" +} +remove_link_provenance() { ruby -rjson -e 'path = ARGV.fetch(0); object = JSON.parse(File.binread(path)); object.fetch("pages").each { |page| page.delete("link") }; File.binwrite(path, JSON.generate(object) + "\n")' "$1"; } +verify_fails() { + local name="$1" bundle="$2" expected="$3" + shift 3 + set +e + output="$(verify "$bundle" "$@" 2>&1)" + exit_code=$? + set -e + test "$exit_code" -eq 2 + if ! grep -Fq "ERROR issue lifecycle preflight:" <<<"$output" || ! grep -Fq "$expected" <<<"$output"; then + echo "$name did not report the expected verifier error: $output" >&2 + exit 1 + fi +} +verify_live_fails() { + local mode="$1" bundle="$2" + local before output exit_code expected + case "$mode" in + live-drift) expected="live verification drift: Issue/PR graph changed" ;; + live-link-drift) expected="live verification drift: page/header inventory changed" ;; + incomplete) expected="simulated missing pagination page" ;; + graphql-media-*) expected="manifest GraphQL media type does not match page inventory" ;; + *) expected="capture stability drift:" ;; + esac + before="$(find "$bundle" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" + set +e + output="$(verify_live "$mode" "$bundle" 2>&1)" + exit_code=$? + set -e + test "$exit_code" -eq 2 + grep -Fq "ERROR issue lifecycle preflight:" <<<"$output" && grep -Fq "$expected" <<<"$output" + test "$(find "$bundle" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$before" +} : >"$api_log" capture normal "$tmp_dir/bundle-a" capture reordered "$tmp_dir/bundle-b" +capture live-link-drift "$tmp_dir/bundle-link-header" +capture canonical-link "$tmp_dir/bundle-canonical-link" +verify "$tmp_dir/bundle-canonical-link" +bundle_before="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" +api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" +verify "$tmp_dir/bundle-a" +test "$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$bundle_before" +test "$(wc -l <"$api_log" | tr -d ' ')" = "$api_calls_before" expected_files=$'manifest.json\nmanifest.json.sha256\npreflight.json\npreflight.json.sha256' actual_files="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -print | sed 's|.*/||' | sort)" test "$actual_files" = "$expected_files" @@ -182,7 +280,7 @@ abort "missing graph" unless graph.fetch("label_catalog").length == 2 && graph.f issue = graph.fetch("items").find { |item| item["number"] == 1 } pull = graph.fetch("items").find { |item| item["number"] == 2 } blocker = graph.fetch("items").find { |item| item["number"] == 3 } -abort "incomplete Issue metadata" unless %w[body labels assignees state].all? { |field| issue.fetch("issue").key?(field) } && issue["comments"].length == 1 && issue["events"].length == 1 && issue.dig("relationships", "blocked_by").map { |entry| entry["number"] } == [3] +abort "incomplete Issue metadata" unless issue.dig("issue", "body") == "Issue one 研究" && %w[body labels assignees state].all? { |field| issue.fetch("issue").key?(field) } && issue["comments"].length == 1 && issue["events"].length == 1 && issue.dig("relationships", "blocked_by").map { |entry| entry["number"] } == [3] abort "ambiguous blocker" unless issue.dig("relationships", "blocked_by", 0, "repository", "nameWithOwner") == "example/repo" abort "ambiguous parent" unless blocker.dig("relationships", "parent", "repository", "nameWithOwner") == "example/repo" abort "missing close ref" unless issue.dig("relationships", "closed_by_pull_requests").map { |entry| entry["number"] } == [2] @@ -196,6 +294,203 @@ abort "missing conditional stability pages" unless manifest.fetch("pages").any? abort "wrong counts" unless manifest.fetch("counts") == {"issues" => 2, "pull_requests" => 1, "labels" => 2, "issue_comments" => 2, "issue_events" => 2} RUBY +ruby -rjson - "$tmp_dir/bundle-link-header/manifest.json" <<'RUBY' +pages = JSON.parse(File.read(ARGV.fetch(0))).fetch("pages").select { |page| page["protocol"] == "rest" && page["request"].include?("/labels?") } +abort "capture Link was not recorded" unless pages.find { |page| page["phase"] == "capture" }.fetch("link").include?('rel="prev"') +abort "304 stability incorrectly copied capture Link" unless pages.find { |page| page["phase"] == "stability_check" }.fetch("link").nil? +RUBY + +local_api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" + +copy_bundle verify-v1-pages +remove_link_provenance "$tmp_dir/verify-v1-pages/manifest.json" +rehash "$tmp_dir/verify-v1-pages" manifest.json +verify "$tmp_dir/verify-v1-pages" +cp -R "$tmp_dir/bundle-canonical-link" "$tmp_dir/verify-v1-multipage" +remove_link_provenance "$tmp_dir/verify-v1-multipage/manifest.json" +rehash "$tmp_dir/verify-v1-multipage" manifest.json +verify_fails v1-multipage "$tmp_dir/verify-v1-multipage" "legacy manifest cannot verify multi-page REST provenance" + +copy_bundle verify-missing +rm "$tmp_dir/verify-missing/manifest.json.sha256" +verify_fails missing "$tmp_dir/verify-missing" "bundle file set is invalid" + +copy_bundle verify-extra +touch "$tmp_dir/verify-extra/unexpected" +verify_fails extra "$tmp_dir/verify-extra" "bundle file set is invalid" + +copy_bundle verify-symlink +rm "$tmp_dir/verify-symlink/preflight.json" +ln -s "$tmp_dir/bundle-a/preflight.json" "$tmp_dir/verify-symlink/preflight.json" +verify_fails symlink "$tmp_dir/verify-symlink" "bundle entry preflight.json is not a regular file" + +copy_bundle verify-tampered +printf ' ' >>"$tmp_dir/verify-tampered/preflight.json" +verify_fails tampered "$tmp_dir/verify-tampered" "preflight.json digest mismatch" + +copy_bundle verify-digest +printf '%064d preflight.json\n' 0 >"$tmp_dir/verify-digest/preflight.json.sha256" +verify_fails digest "$tmp_dir/verify-digest" "preflight.json digest mismatch" + +copy_bundle verify-noncanonical +ruby -rjson -e 'path = ARGV.fetch(0); File.binwrite(path, JSON.pretty_generate(JSON.parse(File.binread(path))) + "\n")' "$tmp_dir/verify-noncanonical/preflight.json" +rehash "$tmp_dir/verify-noncanonical" preflight.json +verify_fails noncanonical "$tmp_dir/verify-noncanonical" "preflight.json is not canonical JSON" + +copy_bundle verify-schema +set_json_field "$tmp_dir/verify-schema/manifest.json" schema '"monday.issue_lifecycle_manifest.v2"' +rehash "$tmp_dir/verify-schema" manifest.json +verify_fails schema "$tmp_dir/verify-schema" "manifest schema is invalid" + +verify_fails scope "$tmp_dir/bundle-a" "manifest scope is invalid" --repo other/repo + +copy_bundle verify-api +set_json_field "$tmp_dir/verify-api/manifest.json" api '{"rest_version":"wrong"}' +rehash "$tmp_dir/verify-api" manifest.json +verify_fails api "$tmp_dir/verify-api" "manifest API provenance is invalid" + +copy_bundle verify-pages +set_json_field "$tmp_dir/verify-pages/manifest.json" pages '[]' +rehash "$tmp_dir/verify-pages" manifest.json +verify_fails pages "$tmp_dir/verify-pages" "manifest page inventory is empty" + +copy_bundle verify-counts +set_json_field "$tmp_dir/verify-counts/manifest.json" counts '{"issues":999}' +rehash "$tmp_dir/verify-counts" manifest.json +verify_fails counts "$tmp_dir/verify-counts" "manifest counts do not match preflight" + +copy_bundle verify-branch +set_json_field "$tmp_dir/verify-branch/manifest.json" default_branch_sha '"cccccccccccccccccccccccccccccccccccccccc"' +rehash "$tmp_dir/verify-branch" manifest.json +verify_fails branch "$tmp_dir/verify-branch" "manifest preflight identity is invalid" + +copy_bundle verify-item-schema +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("items").first["unexpected"] = true + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-item-schema/preflight.json" +resign_preflight "$tmp_dir/verify-item-schema" +verify_fails item-schema "$tmp_dir/verify-item-schema" "preflight Issue #1 schema is invalid" + +copy_bundle verify-pr-schema +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("items").find { |item| item["kind"] == "pull_request" }.fetch("pull_request").delete("commits") + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-pr-schema/preflight.json" +resign_preflight "$tmp_dir/verify-pr-schema" +verify_fails pr-schema "$tmp_dir/verify-pr-schema" "preflight PR #2 schema is invalid" + +copy_bundle verify-relationship-schema +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("items").find { |item| item["kind"] == "issue" }.fetch("relationships").delete("blocked_by") + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-relationship-schema/preflight.json" +resign_preflight "$tmp_dir/verify-relationship-schema" +verify_fails relationship-schema "$tmp_dir/verify-relationship-schema" "preflight Issue #1 schema is invalid" + +copy_bundle verify-rest-etag +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("pages").select { |page| page["protocol"] == "rest" }.each { |page| page["etag"] = nil } + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-rest-etag/manifest.json" +rehash "$tmp_dir/verify-rest-etag" manifest.json +verify_fails rest-etag "$tmp_dir/verify-rest-etag" "manifest REST ETag provenance is invalid" + +copy_bundle verify-page-omission +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("pages").reject! { |page| page["request"] == "repos/example/repo/issues/1" } + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-page-omission/manifest.json" +rehash "$tmp_dir/verify-page-omission" manifest.json +verify_fails page-omission "$tmp_dir/verify-page-omission" "manifest REST page inventory does not match preflight" + +copy_bundle verify-link-target +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + page = object.fetch("pages").find { |entry| entry["phase"] == "capture" && entry["request"].include?("/labels?") } + page["link"] = %q(; rel="next") + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-link-target/manifest.json" +rehash "$tmp_dir/verify-link-target" manifest.json +verify_fails link-target "$tmp_dir/verify-link-target" "manifest REST pagination chain has an extra page" + +cp -R "$tmp_dir/bundle-canonical-link" "$tmp_dir/verify-page-size" +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("pages").each do |page| + page["request"].sub!("per_page=100", "per_page=1") if page["request"].include?("repositories/123456/labels") + page["link"].sub!("per_page=100", "per_page=1") if page["link"] + end + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-page-size/manifest.json" +rehash "$tmp_dir/verify-page-size" manifest.json +verify_fails page-size "$tmp_dir/verify-page-size" "manifest REST Link scope is invalid" + +copy_bundle verify-entry-identity +ruby -rjson -e ' + path = ARGV.fetch(0) + object = JSON.parse(File.binread(path)) + object.fetch("items").first.fetch("comments").first.clear + File.binwrite(path, JSON.generate(object) + "\n") +' "$tmp_dir/verify-entry-identity/preflight.json" +resign_preflight "$tmp_dir/verify-entry-identity" +verify_fails entry-identity "$tmp_dir/verify-entry-identity" "preflight item #1 metadata is invalid" + +for field in comment comment-host relationship-host relationship-repo relationship-kind relationship-number label commit review review-comment check-run status; do + expected="preflight PR #2 schema is invalid" + [[ "$field" != comment && "$field" != comment-host && "$field" != label ]] || expected="preflight item #1 metadata is invalid" + [[ "$field" != relationship-* ]] || expected="preflight Issue #1 schema is invalid" + copy_bundle "verify-scope-$field" + ruby -rjson -e ' + path, field = ARGV + object = JSON.parse(File.binread(path)) + case field + when "comment" then object.fetch("items").first.fetch("comments").first["issue_url"].sub!("example/repo", "other/repo") + when "comment-host" then object.fetch("items").first.fetch("comments").first["issue_url"].sub!("api.github.com", "evil.example") + when "relationship-host" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("github.com", "evil.example") + when "relationship-repo" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first.dig("repository")["nameWithOwner"] = "other/repo" + when "relationship-kind" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("/issues/", "/pull/") + when "relationship-number" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("/3", "/999") + when "label" then labels = object.fetch("items").first.dig("issue", "labels"); labels.first.merge!("id" => 999, "name" => "outside-catalog"); labels.sort_by! { |label| label["id"] } + else + pull = object.fetch("items").find { |item| item["kind"] == "pull_request" }.fetch("pull_request") + case field + when "commit" then pull.fetch("commits").first["sha"] = "d" * 40 + when "review" then pull.fetch("reviews").first.dig("_links", "pull_request")["href"].sub!("example/repo", "other/repo") + when "review-comment" then pull.fetch("review_comments").first["pull_request_url"].sub!("example/repo", "other/repo") + when "check-run" then pull.dig("check_runs", "check_runs").first["head_sha"] = "d" * 40 + when "status" then pull.fetch("statuses").first.delete("id") + end + end + File.binwrite(path, JSON.generate(object) + "\n") + ' "$tmp_dir/verify-scope-$field/preflight.json" "$field" + resign_preflight "$tmp_dir/verify-scope-$field" + verify_fails "scope-$field" "$tmp_dir/verify-scope-$field" "$expected" +done + +test "$(wc -l <"$api_log" | tr -d ' ')" = "$local_api_calls_before" + +live_bundle_before="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" +live_api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" +verify_live normal "$tmp_dir/bundle-a" +test "$(wc -l <"$api_log" | tr -d ' ')" -gt "$live_api_calls_before" +test "$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$live_bundle_before" +for mode in live-drift live-link-drift incomplete missing-link capture-race header-race issue-detail-race closed-pr-race graphql-media-race graphql-media-missing; do + verify_live_fails "$mode" "$tmp_dir/bundle-a" +done + for mode in incomplete missing-link missing-relationship capture-race header-race issue-detail-race closed-pr-race graphql-media-race graphql-media-missing; do set +e output="$(capture "$mode" "$tmp_dir/$mode" 2>&1)"