Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
nightly_manifest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# This file is part of the Ansel project.
3# Copyright (C) 2026 Aurélien PIERRE.
4#
5# Ansel is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansel is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansel. If not, see <http://www.gnu.org/licenses/>.
17
18"""The nightly manifest: one JSON file naming the newest build of every format.
19
20Everything downstream of the nightly builds reads this file instead of the GitHub
21API: the website's download buttons, the in-app update check, the Homebrew cask and
22the Scoop manifest. That is the point of it -- one place that knows what "latest"
23means, written once per night by CI, served as a static file with no rate limit and
24no dependency on GitHub being reachable from a user's machine.
25
26 nightly_manifest.py manifest > nightly.json # from the GitHub releases
27 nightly_manifest.py cask nightly.json # Homebrew cask (stdout)
28 nightly_manifest.py scoop nightly.json # Scoop manifest (stdout)
29
30The manifest walks the `nightly-YYYY-MM` pre-releases newest first and takes, per
31format, the most recently uploaded asset. Formats are recognised by filename shape,
32which is also what the running application matches itself against (see
33src/common/updates.c), so the two must agree: change one, change the other.
34
35sha256 is computed by downloading each asset. GitHub's API started publishing a
36`digest` for release assets in 2025, and it is used when present; the download is the
37fallback that keeps the manifest complete on assets uploaded before that.
38"""
39
40import argparse
41import datetime
42import hashlib
43import json
44import os
45import re
46import sys
47import urllib.request
48
49REPO = "aurelienpierreeng/ansel"
50API = "https://api.github.com"
51DOCKER_IMAGE = "aurelienpierre/ansel"
52DOCKER_HUB = "https://hub.docker.com/v2/repositories"
53TAG_RE = re.compile(r"^nightly-\d{4}-\d{2}$")
54
55# Format key -> filename predicate. Keys are stable identifiers: the app, the cask and
56# the Scoop manifest all address the manifest by them.
57FORMATS = {
58 "appimage": lambda n: n.endswith("-x86_64.AppImage"),
59 "flatpak": lambda n: n.endswith("-x86_64.flatpak"),
60 "dmg-arm64": lambda n: n.endswith("-arm64.dmg"),
61 "dmg-i386": lambda n: n.endswith("-i386.dmg"),
62 "exe": lambda n: n.endswith("-win64.exe"),
63 # `docker save | zstd` of the image the same night pushed to Docker Hub.
64 "docker": lambda n: n.endswith("-docker.tar.zst"),
65}
66
67# Ansel-0.0.0+4802.gd5a317e072-x86_64.flatpak -> ("0.0.0+4802.gd5a317e072", "d5a317e072")
68VERSION_RE = re.compile(r"^[Aa]nsel-(?P<version>[0-9][^-]*?\.g(?P<hash>[0-9a-f]+))-")
69# The commit count after "+": monotonic by construction, which upload time is not --
70# an asset moved between releases, or re-uploaded, gets a fresh timestamp.
71COMMITS_RE = re.compile(r"\+(\d+)[.~]g")
72
73
74def build_rank(name, uploaded):
75 """Sort key for "newest": commit count first, upload time to break ties."""
76 m = COMMITS_RE.search(name)
77 return (int(m.group(1)) if m else -1, uploaded or "")
78# The same version string as a bare Docker tag: 0.0.0+4802.gd5a317e072 -- except that a
79# Docker tag cannot contain "+", so the workflow writes it as 0.0.0-4802.gd5a317e072.
80VERSION_TAG_RE = re.compile(r"^[0-9][^-]*-\d+\.g(?P<hash>[0-9a-f]+)$")
81
82
83def api(path, token):
84 req = urllib.request.Request(f"{API}{path}", headers={
85 "Accept": "application/vnd.github+json",
86 "User-Agent": "ansel-nightly-manifest",
87 **({"Authorization": f"Bearer {token}"} if token else {}),
88 })
89 with urllib.request.urlopen(req, timeout=60) as r:
90 return json.loads(r.read().decode("utf-8"))
91
92
94 h = hashlib.sha256()
95 req = urllib.request.Request(url, headers={"User-Agent": "ansel-nightly-manifest"})
96 with urllib.request.urlopen(req, timeout=600) as r:
97 for chunk in iter(lambda: r.read(1 << 20), b""):
98 h.update(chunk)
99 return h.hexdigest()
100
101
103 """The newest versioned tag on Docker Hub, or None if the image has no such tag yet.
104
105 The workflow tags each push twice, `current` and the version string; `current` is
106 what a user pulls, the version tag is what tells us which nightly it is. Sorted by
107 push time rather than by name so a re-push of an older version cannot win."""
108 try:
109 data = api_raw(f"{DOCKER_HUB}/{DOCKER_IMAGE}/tags?page_size=100&ordering=last_updated")
110 except Exception as e: # noqa: BLE001 -- Docker Hub down must not fail the manifest
111 print(f"docker hub unreachable: {e}", file=sys.stderr)
112 return None
113 tags = [t for t in data.get("results", []) if t["name"] != "current" and VERSION_TAG_RE.match(t["name"])]
114 if not tags:
115 return None
116 t = max(tags, key=lambda t: t["tag_last_pushed"])
117 m = VERSION_TAG_RE.match(t["name"])
118 digest = t.get("digest") or next((i.get("digest") for i in t.get("images", []) if i.get("digest")), None)
119 return {
120 "image": DOCKER_IMAGE,
121 "tag": t["name"],
122 "pull": f"{DOCKER_IMAGE}:{t['name']}",
123 "digest": digest,
124 "size": t.get("full_size"),
125 "uploaded": t["tag_last_pushed"],
126 "version": t["name"],
127 "commit_short": m.group("hash"),
128 }
129
130
131def api_raw(url):
132 req = urllib.request.Request(url, headers={"User-Agent": "ansel-nightly-manifest"})
133 with urllib.request.urlopen(req, timeout=60) as r:
134 return json.loads(r.read().decode("utf-8"))
135
136
137def build_manifest(token, with_hashes=True):
138 releases = [r for r in api(f"/repos/{REPO}/releases?per_page=30", token)
139 if TAG_RE.match(r["tag_name"]) and not r["draft"]]
140 releases.sort(key=lambda r: r["tag_name"], reverse=True)
141
142 # Per format, the matching asset with the highest commit count (build_rank), not
143 # the first one the API lists -- a release holds a month of nightlies and asset
144 # order is not build order -- and not the most recently uploaded either: an asset
145 # moved from the retired rolling release into its month carries a fresh upload
146 # time, and would have outranked that night's genuinely newer build. Releases are
147 # walked newest first, and a format found in a newer release is never displaced
148 # by an older one.
149 newest = {}
150 for rel in releases:
151 for a in rel["assets"]:
152 for key, match in FORMATS.items():
153 if not match(a["name"]):
154 continue
155 cur = newest.get(key)
156 if cur and (cur["release"] > rel["tag_name"]
157 or build_rank(cur["name"], cur["uploaded"]) >= build_rank(a["name"], a["updated_at"])):
158 continue
159 m = VERSION_RE.match(a["name"])
160 newest[key] = {
161 "name": a["name"],
162 "url": a["browser_download_url"],
163 "size": a["size"],
164 "uploaded": a["updated_at"],
165 "release": rel["tag_name"],
166 "version": m.group("version") if m else None,
167 "commit_short": m.group("hash") if m else None,
168 "sha256": (a.get("digest") or "").removeprefix("sha256:") or None,
169 }
170 if len(newest) == len(FORMATS):
171 break
172
173 if with_hashes:
174 for key, entry in newest.items():
175 if not entry["sha256"]:
176 print(f"hashing {entry['name']} ...", file=sys.stderr)
177 entry["sha256"] = sha256_of_url(entry["url"])
178
179 # The release asset is the record; Docker Hub is the convenient way to get it.
180 # Merge the hub's pull reference and digest into the asset entry when the hub has
181 # the same version, and keep the asset alone when it does not (hub down, or a
182 # push that failed after the save).
183 hub = docker_entry()
184 if "docker" in newest:
185 if hub and hub["version"].replace("-", "+", 1) == newest["docker"]["version"]:
186 newest["docker"].update({"image": hub["image"], "pull": hub["pull"], "digest": hub["digest"]})
187 elif hub:
188 newest["docker"] = hub
189
190 # The full commit for the newest build, resolved once: the app compares against its
191 # own darktable_commit_hash, which is the full SHA, and filenames carry ten digits.
192 commits = {}
193 for entry in newest.values():
194 short = entry["commit_short"]
195 if short and short not in commits:
196 try:
197 commits[short] = api(f"/repos/{REPO}/commits/{short}", token)["sha"]
198 except Exception as e: # noqa: BLE001 -- best effort, the short hash still works
199 print(f"could not resolve {short}: {e}", file=sys.stderr)
200 commits[short] = None
201 entry["commit"] = commits.get(short)
202
203 return {
204 "schema": 1,
205 "generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
206 "channel": "nightly",
207 "repo": REPO,
208 "download_page": "https://ansel.photos/en/download/",
209 "formats": dict(sorted(newest.items())),
210 }
211
212
213def render_cask(manifest):
214 arm = manifest["formats"].get("dmg-arm64")
215 intel = manifest["formats"].get("dmg-i386")
216 if not (arm and intel):
217 sys.exit("cask needs both dmg-arm64 and dmg-i386 in the manifest")
218
219 # Each architecture pins its own version, sha256 and URL: the two are built by
220 # separate runners and one can fail a night the other succeeded. A shared version
221 # with an #{arch} URL template would 404 on the arch that lagged.
222 #
223 # Homebrew wants a comparable version, and the nightly string already is one
224 # (0.0.0+4802.gd5a317e072 sorts by commit count). A cask with `version :latest`
225 # is skipped by plain `brew upgrade`, which is why this file is regenerated
226 # nightly with a real version instead.
227 def block(entry):
228 return (f' version "{entry["version"]}"\n'
229 f' sha256 "{entry["sha256"]}"\n'
230 f' url "{entry["url"]}",\n'
231 f' verified: "github.com/{REPO}/"\n')
232
233 return f'''# Generated by tools/nightly_manifest.py in aurelienpierreeng/ansel -- do not edit.
234cask "ansel-nightly" do
235 on_arm do
236{block(arm)} end
237 on_intel do
238{block(intel)} end
239
240 name "Ansel (nightly)"
241 desc "Photo editor and library manager for digital negatives, nightly build"
242 homepage "https://ansel.photos/"
243
244 livecheck do
245 url "https://ansel.photos/nightly.json"
246 strategy :json do |json|
247 json.dig("formats", "dmg-arm64", "version")
248 end
249 end
250
251 # Nightly builds are not signed or notarized: Gatekeeper quarantines them, and the
252 # first launch needs a right-click > Open. See doc/nightly-distribution.md.
253 app "Ansel.app"
254
255 zap trash: [
256 "~/.config/ansel",
257 "~/.cache/ansel",
258 ]
259end
260'''
261
262
263def render_scoop(manifest):
264 exe = manifest["formats"].get("exe")
265 if not exe:
266 sys.exit("scoop needs exe in the manifest")
267 doc = {
268 "version": exe["version"],
269 "description": "Photo editor and library manager for digital negatives (nightly build)",
270 "homepage": "https://ansel.photos/",
271 "license": "GPL-3.0-or-later",
272 "url": exe["url"],
273 "hash": exe["sha256"],
274 # The NSIS installer; Scoop runs it silently into its own app directory.
275 "innosetup": False,
276 "installer": {"args": ["/S", "/D=$dir"]},
277 "bin": [["bin\\ansel.exe", "ansel"], ["bin\\ansel-cli.exe", "ansel-cli"]],
278 "shortcuts": [["bin\\ansel.exe", "Ansel (nightly)"]],
279 # checkver lets `scoop status` see a newer nightly. No `autoupdate` block on
280 # purpose: this manifest is regenerated by Ansel's own CI every night, so
281 # Scoop's bucket-side auto-bump has nothing to do and must not fight it.
282 "checkver": {
283 "url": "https://ansel.photos/nightly.json",
284 "jsonpath": "$.formats.exe.version",
285 },
286 }
287 return json.dumps(doc, indent=4) + "\n"
288
289
290def render_summary(manifest):
291 """A table for the run summary, one line per format; says so when there is none."""
292 formats = manifest.get("formats", {})
293 if not formats:
294 return "(no nightly-* release carries any asset yet)\n"
295 return "".join(f"{k:10s} {v.get('version') or '?':32s} {v.get('uploaded') or ''}\n"
296 for k, v in formats.items())
297
298
299def check(manifest):
300 """Exit status 0 when the manifest names at least one build, 1 when it is empty.
301
302 The workflow gates publishing on this: an empty manifest -- no nightly-* release
303 yet, or a GitHub API hiccup that returned nothing -- must never overwrite a good
304 file downstream. The first run on master did exactly that to the website's data
305 file before this existed."""
306 return 0 if manifest.get("formats") else 1
307
308
309def render_oneline(manifest):
310 """One line for a commit message: `appimage 0.0.0+4810.g..., exe 0.0.0+4810.g...`."""
311 formats = manifest.get("formats", {})
312 return ", ".join(f"{k} {v.get('version') or '?'}" for k, v in formats.items()) or "no builds"
313
314
315def main():
316 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
317 sub = ap.add_subparsers(dest="cmd", required=True)
318 m = sub.add_parser("manifest"); m.add_argument("--no-hashes", action="store_true")
319 for name in ("cask", "scoop", "summary", "oneline", "check"):
320 sub.add_parser(name).add_argument("manifest")
321 args = ap.parse_args()
322
323 if args.cmd == "manifest":
324 doc = build_manifest(os.environ.get("GITHUB_TOKEN"), with_hashes=not args.no_hashes)
325 json.dump(doc, sys.stdout, indent=1); sys.stdout.write("\n")
326 return
327 doc = json.load(open(args.manifest, encoding="utf-8"))
328 if args.cmd == "check":
329 sys.exit(check(doc))
330 render = {"cask": render_cask, "scoop": render_scoop, "summary": render_summary, "oneline": render_oneline}
331 sys.stdout.write(render[args.cmd](doc))
332
333
334if __name__ == "__main__":
335 main()
const float max
build_rank(name, uploaded)
build_manifest(token, with_hashes=True)