A checklist of the release assets that exist and the ones the CI could still build
Nothing said whether a release was finished. A pipeline step can fail, a
platform can gain a target, a title can be published from a laptop — and the
catalog happily serves a release with three of its seven assets. The only way
to find out was to open `/api/softwares/<name>/builds` one name at a time.
$ bin/rails warp_engine:builds:check
rabbitroller ebitengine v1.1.1 1/7
[x] html
[ ] win_x86
[ ] win_x64
[ ] linux_x64
[ ] linux_arm64
[-] mac_x64
[-] mac_arm64
The CI can build these and the release does not have them:
rabbitroller v1.1.1 (ebitengine): win_x86, win_x64, linux_x64, linux_arm64
5 softwares, 5 releases, 14/32 assets present, 16 missing from CI-built kinds
The third marker is the point. Two different questions were being conflated:
what the **updater** can ingest (`Platform#expected_kinds`) and what the
**pipeline** actually produces. For six of the seven platforms they are the
same list; for ebitengine they are not — the updater accepts macOS builds, the
Woodpecker builder cannot cross-compile them (osxcross), so `mac_x64` and
`mac_arm64` would be reported as gaps on every ebitengine release, for ever.
`[ ]` is now a real gap and `[-]` is "upload it by hand or not at all", so the
list stays worth reading.
That knowledge is data, next to the templates that produce it:
`PipelineConfig::BUILT_KINDS`, reachable through the adapter as
`built_kinds(platform)`. An adapter that cannot answer — no CI configured, a
platform with no builder — returns nil, and then nothing is excused: every
missing kind counts as a gap.
`WarpEngine::AssetCoverage` computes it (`#rows`, `#missing_rows`, `#totals`)
and `AssetCoverageChecklist` renders it, so the numbers are testable without a
terminal. Options are env vars: NAME, PLATFORM, RELEASES=all, ONLY=missing, and
STRICT=1 for a non-zero exit in CI. The default is the newest release per
software, using the same non-`dev-` rule the catalog API uses for "latest".
33 new examples, including two that keep BUILT_KINDS honest: every claimed kind
has to be a kind the updater knows, and has to appear in the platform's rendered
pipeline. Adding a platform without teaching this list about it fails the suite.
Ships in 0.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
module WarpEngine
|
||||
class AssetCoverageChecklist
|
||||
MARKERS = {
|
||||
present: "[x]",
|
||||
missing: "[ ]",
|
||||
manual: "[-]",
|
||||
unexpected: "[+]"
|
||||
}.freeze
|
||||
|
||||
def initialize(coverage, only_missing: false)
|
||||
@coverage = coverage
|
||||
@only_missing = only_missing
|
||||
end
|
||||
|
||||
attr_reader :coverage, :only_missing
|
||||
|
||||
def lines
|
||||
[ *header, *body, *footer ]
|
||||
end
|
||||
|
||||
def to_s = lines.join("\n")
|
||||
|
||||
private
|
||||
|
||||
def header
|
||||
[
|
||||
"WarpEngine release asset coverage — #{coverage.releases == :all ? 'every release' : 'latest release per software'}",
|
||||
"expected: the kinds a platform's updater registers · #{MARKERS[:missing]} the CI builds it · " \
|
||||
"#{MARKERS[:manual]} the CI does not, upload it by hand",
|
||||
""
|
||||
]
|
||||
end
|
||||
|
||||
def body
|
||||
shown = only_missing ? coverage.missing_rows : coverage.rows
|
||||
return [ "Nothing to report.", "" ] if shown.empty?
|
||||
|
||||
shown.flat_map { |row| [ title(row), *checklist(row), "" ] }
|
||||
end
|
||||
|
||||
def title(row)
|
||||
parts = [ row.software.name, row.platform&.name || "unknown platform" ]
|
||||
parts << (row.released? ? "v#{row.version}" : "no release")
|
||||
parts << ratio(row) if row.released? && row.expected.any?
|
||||
parts.join(" ")
|
||||
end
|
||||
|
||||
def ratio(row) = "#{(row.expected & row.present).size}/#{row.expected.size}"
|
||||
|
||||
def checklist(row)
|
||||
return [ " (this platform is not in the registry, so nothing is expected)" ] if row.unknown_platform?
|
||||
return [ " (no release to check)" ] unless row.released?
|
||||
return [ " (this platform expects no assets)" ] if row.expected.empty? && row.present.empty?
|
||||
|
||||
row.expected.map { |kind| " #{MARKERS[row.state_of(kind)]} #{kind}" } +
|
||||
row.unexpected.map { |kind| " #{MARKERS[:unexpected]} #{kind} (not expected on #{row.platform&.name})" }
|
||||
end
|
||||
|
||||
def footer
|
||||
totals = coverage.totals
|
||||
lines = []
|
||||
|
||||
if coverage.missing_rows.any? && !only_missing
|
||||
lines << "The CI can build these and the release does not have them:"
|
||||
lines += coverage.missing_rows.map do |row|
|
||||
" #{row.software.name} v#{row.version} (#{row.platform&.name}): #{row.buildable_missing.join(', ')}"
|
||||
end
|
||||
lines << ""
|
||||
end
|
||||
|
||||
lines << format(
|
||||
"%d softwares, %d releases, %d/%d assets present, %d missing from CI-built kinds",
|
||||
totals[:softwares], totals[:releases], totals[:present], totals[:expected], totals[:missing]
|
||||
)
|
||||
lines << "#{totals[:incomplete_softwares]} softwares are missing an asset the CI builds" if totals[:missing].positive?
|
||||
lines << "#{totals[:manual_missing]} assets are expected but this CI does not build them" if totals[:manual_missing].positive?
|
||||
lines << "#{totals[:without_release]} softwares have no release yet" if totals[:without_release].positive?
|
||||
lines << "#{totals[:unknown_platform]} softwares are on a platform the registry does not know" if totals[:unknown_platform].positive?
|
||||
lines << "#{totals[:unexpected]} assets are not expected on their platform" if totals[:unexpected].positive?
|
||||
lines
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
module WarpEngine
|
||||
class AssetCoverage
|
||||
Row = Struct.new(:software, :release, :platform, :expected, :present, :ci_kinds, keyword_init: true) do
|
||||
def missing = expected - present
|
||||
|
||||
def buildable_missing
|
||||
return missing if ci_kinds.nil?
|
||||
|
||||
missing & ci_kinds
|
||||
end
|
||||
|
||||
def manual_missing = missing - buildable_missing
|
||||
|
||||
def unexpected = present - expected
|
||||
|
||||
def complete? = release.present? && missing.empty?
|
||||
|
||||
def actionable? = release.present? && buildable_missing.any?
|
||||
|
||||
def unknown_platform? = platform.nil?
|
||||
|
||||
def released? = release.present?
|
||||
|
||||
def version = release&.version
|
||||
|
||||
def state_of(kind)
|
||||
return :present if present.include?(kind)
|
||||
return :manual if ci_kinds && !ci_kinds.include?(kind)
|
||||
|
||||
:missing
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(name: nil, platform: nil, releases: :latest)
|
||||
@name = name.presence
|
||||
@platform = platform.presence
|
||||
@releases = releases == :all ? :all : :latest
|
||||
end
|
||||
|
||||
attr_reader :name, :platform, :releases
|
||||
|
||||
def rows
|
||||
@rows ||= scope.flat_map { |software| rows_for(software) }
|
||||
end
|
||||
|
||||
def missing_rows = rows.select(&:actionable?)
|
||||
|
||||
def incomplete_rows = rows.select { |row| row.released? && !row.complete? }
|
||||
|
||||
def totals
|
||||
counted = rows.select(&:released?)
|
||||
|
||||
{
|
||||
softwares: rows.map { |row| row.software.id }.uniq.size,
|
||||
releases: counted.size,
|
||||
expected: counted.sum { |row| row.expected.size },
|
||||
present: counted.sum { |row| (row.expected & row.present).size },
|
||||
missing: counted.sum { |row| row.buildable_missing.size },
|
||||
manual_missing: counted.sum { |row| row.manual_missing.size },
|
||||
unexpected: counted.sum { |row| row.unexpected.size },
|
||||
incomplete_softwares: missing_rows.map { |row| row.software.id }.uniq.size,
|
||||
without_release: rows.count { |row| !row.released? },
|
||||
unknown_platform: rows.count(&:unknown_platform?)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
relation = WarpEngine::Software.order(:name).includes(releases: :release_assets)
|
||||
relation = relation.where(name: name) if name
|
||||
relation = relation.where(platform: platform) if platform
|
||||
relation
|
||||
end
|
||||
|
||||
def rows_for(software)
|
||||
expected = expected_kinds(software)
|
||||
selected = select_releases(software.releases.to_a)
|
||||
return [ row(software, nil, expected) ] if selected.empty?
|
||||
|
||||
selected.map { |release| row(software, release, expected) }
|
||||
end
|
||||
|
||||
def row(software, release, expected)
|
||||
Row.new(
|
||||
software: software,
|
||||
release: release,
|
||||
platform: WarpEngine::Platform.find(software.platform),
|
||||
expected: expected,
|
||||
present: release ? release.release_assets.map(&:kind).uniq : [],
|
||||
ci_kinds: ci_kinds(software.platform)
|
||||
)
|
||||
end
|
||||
|
||||
def ci_kinds(platform)
|
||||
@ci_kinds ||= {}
|
||||
return @ci_kinds[platform] if @ci_kinds.key?(platform)
|
||||
|
||||
ci = WarpEngine.ci
|
||||
@ci_kinds[platform] = ci.respond_to?(:built_kinds) ? ci.built_kinds(platform) : nil
|
||||
end
|
||||
|
||||
def expected_kinds(software)
|
||||
WarpEngine::Platform.find(software.platform)&.expected_kinds || []
|
||||
end
|
||||
|
||||
def select_releases(all)
|
||||
return sorted(all) if releases == :all
|
||||
|
||||
[ latest(all) ].compact
|
||||
end
|
||||
|
||||
def sorted(all) = all.sort_by { |release| [ release.created_at || Time.at(0), release.id ] }.reverse
|
||||
|
||||
def latest(all)
|
||||
candidates = all.reject { |release| release.version.to_s.start_with?("dev-") }
|
||||
candidates = all if candidates.empty?
|
||||
sorted(candidates).first
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user