Module: Everywhere::UpdateManifest

Defined in:
lib/everywhere/update_manifest.rb

Overview

The update-feed contract: bucket key layout + latest.json / index.json bodies. This is THE interface between every publish, the shell's Rust updater, and the Platform's release publishing — all three speak exactly these shapes, so keep changes here schema-versioned and additive.

Layout (channel is a release channel — stable/beta — never the receipt's distribution_channel):

{prefix?}/{channel}/{os}/{arch}/
latest.json            mutable pointer, written last, Cache-Control: no-cache
index.json             append-only publish history (rollback + stats source)
My-App-1.2.0.zip       immutable + .minisig sibling
My-App-latest.zip      convenience alias (CopyObject) for download links

The shell only ever does:

GET {updates.url}/{channel}/{os}/{arch}/latest.json?current={version}

— a raw bucket/CDN or a dynamic Platform endpoint serve the identical JSON.

Constant Summary collapse

SCHEMA =
1
IMMUTABLE_CACHE =
"public, max-age=31536000, immutable"
MUTABLE_CACHE =
"no-cache"

Class Method Summary collapse

Class Method Details

.artifact_key(filename, **loc) ⇒ Object



36
# File 'lib/everywhere/update_manifest.rb', line 36

def artifact_key(filename, **loc) = "#{dir(**loc)}/#{filename}"

.dir(channel:, os:, arch:, prefix: nil) ⇒ Object



32
33
34
# File 'lib/everywhere/update_manifest.rb', line 32

def dir(channel:, os:, arch:, prefix: nil)
  [prefix, channel, os, arch].compact.reject(&:empty?).join("/")
end

.entry(receipt:, url:, signature:, channel:, notes: nil, notes_html: nil, published_at: Time.now.utc) ⇒ Object

One latest.json body (also an index.json entry), built from a build receipt (dist/release.json) + publish-time facts.

Release notes ship in two forms: notes is the human-authored source (markdown; plain text is valid markdown), notes_html is pre-rendered sanitized-by-construction HTML the app can inject straight into a changelog modal via the bridge. Pass notes_html explicitly to override the default markdown rendering (the Platform does — its notes are authored rich).



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/everywhere/update_manifest.rb', line 63

def entry(receipt:, url:, signature:, channel:, notes: nil, notes_html: nil, published_at: Time.now.utc)
  artifact = receipt.fetch("artifact")
  sha256 = artifact.fetch("checksum").to_s.delete_prefix("sha256:")
  notes_html ||= render_notes_html(notes)

  {
    "schema" => SCHEMA,
    "app" => {
      "name" => receipt.dig("app", "name"),
      "bundle_id" => receipt.dig("app", "bundle_id")
    },
    "channel" => channel,
    "os" => receipt.dig("target", "os"),
    "arch" => receipt.dig("target", "arch"),
    "version" => receipt.dig("app", "version"),
    "published_at" => published_at.iso8601,
    "notes" => notes,
    "notes_html" => notes_html,
    "url" => url,
    "sha256" => sha256,
    "size_bytes" => artifact.fetch("size_bytes"),
    "signature" => signature,
    "manifest_checksum" => receipt["manifest_checksum"]
  }.compact
end

.index_append(existing_json, entry) ⇒ Object

index.json: append-only history, newest first. Re-publishing the same version replaces its entry (idempotent re-runs don't duplicate).



91
92
93
94
95
# File 'lib/everywhere/update_manifest.rb', line 91

def index_append(existing_json, entry)
  entries = existing_json ? JSON.parse(existing_json).fetch("entries", []) : []
  entries = entries.reject { |e| e["version"] == entry["version"] }
  JSON.pretty_generate({ "schema" => SCHEMA, "entries" => [entry] + entries }) << "\n"
end

.index_find(index_json, version) ⇒ Object



97
98
99
100
101
# File 'lib/everywhere/update_manifest.rb', line 97

def index_find(index_json, version)
  return nil unless index_json

  JSON.parse(index_json).fetch("entries", []).find { |e| e["version"] == version }
end

.index_key(**loc) ⇒ Object



38
# File 'lib/everywhere/update_manifest.rb', line 38

def index_key(**loc)              = "#{dir(**loc)}/index.json"

.latest_alias(filename, version) ⇒ Object

"My-App-1.2.0.zip" -> "My-App-latest.zip" (keeps the extension).



49
50
51
52
# File 'lib/everywhere/update_manifest.rb', line 49

def latest_alias(filename, version)
  aliased = filename.sub("-#{version}", "-latest")
  aliased == filename ? nil : aliased
end

.latest_key(**loc) ⇒ Object



37
# File 'lib/everywhere/update_manifest.rb', line 37

def latest_key(**loc)             = "#{dir(**loc)}/latest.json"

.notes_asset_key(filename, channel:, version:, prefix: nil) ⇒ Object

Where images embedded in release notes live: channel-scoped (shared by every os/arch target of the release), versioned so they're immutable and rollback-safe. notes_html rewrites embedded images to these URLs at publish time — signed/expiring storage URLs must never leak into a feed.



44
45
46
# File 'lib/everywhere/update_manifest.rb', line 44

def notes_asset_key(filename, channel:, version:, prefix: nil)
  "#{[ prefix, channel ].compact.reject(&:empty?).join("/")}/notes/#{version}/#{filename}"
end

.render_notes_html(notes) ⇒ Object

Markdown -> HTML for notes_html. Kramdown with GFM-ish niceties off the shelf; raw HTML in the source passes through (the feed is the app developer's own signed content — same trust as the artifact itself).



108
109
110
111
112
113
# File 'lib/everywhere/update_manifest.rb', line 108

def render_notes_html(notes)
  return nil if notes.nil? || notes.strip.empty?

  require "kramdown"
  Kramdown::Document.new(notes, auto_ids: false, entity_output: :as_char).to_html.strip
end

.to_json(entry) ⇒ Object



103
# File 'lib/everywhere/update_manifest.rb', line 103

def to_json(entry) = JSON.pretty_generate(entry) << "\n"