Summary
`python -m appimage.ctl update-tools` is supposed to move every toolchain pin (Python interpreter, `appimagetool`, runtime stub, the `appimage` module itself) forward to whatever's currently available. Instead it fails with a spurious `sha256 mismatch` error precisely when there's real work to do — i.e. when the pinned upstream artifact has actually changed since the last pin. That's the one case `update-tools` exists to handle, so it currently can't do its job whenever it would matter.
Root cause
In `appimage/ctl/_appimagetool.py`, both `_resolve_appimagetool()` (~line 283) and `_resolve_runtime_file()` (~line 363):
```python
expected = resolved.appimagetool_sha256 or api_sha256
```
`update_tools()` (in `appimage/ctl/update_tools.py`) builds `resolved` from the existing, unmodified `pyproject.toml` config, then calls `_pinned_download_fields(resolved, ..., existing=set(), force_latest=True)`. `existing=set()` tells that function to attempt to resolve every field fresh — but the underlying `resolved` object it passes into `_resolve_appimagetool`/`_resolve_runtime_file` still carries the old, already-configured pin values. Those functions verify the freshly-downloaded file against `resolved.appimagetool_sha256`/`resolved.runtime_sha256` (the old pin) if set, instead of trusting the digest GitHub itself publishes for the asset (`api_sha256`, already fetched for free at no extra cost) and writing that forward.
Net effect: the freshly downloaded (genuinely newer) artifact never matches the stale pin still sitting in `resolved`, so the mismatch check always fires whenever the upstream artifact has actually moved — which is exactly the scenario `update-tools` is meant to handle.
Reproduction
Minimal repro, isolated from any real project:
```toml
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
[project.scripts]
myapp = "myapp:main"
[tool.hatch.build.targets.wheel]
packages = ["myapp"]
[tool.appimage]
app = "myapp"
entry_point = "myapp"
python = "3.11"
Deliberately stale pin: an appimagetool build that was current a few
days ago, before upstream published a newer build.
appimagetool_version = "appimagetool, continuous build (git version 8c8c91f), build 295 built on 2025-12-04 17:56:36 UTC"
appimagetool_sha256 = "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0"
verify_downloads = true
```
```
$ python -m appimage.ctl update-tools
...
Resolving Python 3.11 download URL...
Downloading appimagetool-x86_64.AppImage
Error: appimagetool sha256 mismatch for .../build/appimagetool-x86_64.AppImage: expected a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0, got ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0. A freshly downloaded file is removed automatically so a retry re-downloads cleanly; a locally configured or already-cached file is left as-is - remove it yourself and retry, or correct the configured hash.
```
Deterministic, not a network fluke — retrying produces the identical error every time, since it always re-downloads the same (genuinely newer) release and compares it against the same stale pin.
Current workaround
Manually delete the stale `_version`/`_sha256` fields (`appimagetool_version`/`appimagetool_sha256`, `runtime_version`/`runtime_sha256`, `appimage_version`/`appimage_sha256`, `appimagectl_version`) from `pyproject.toml` first, then run `enable-reproducible` instead of `update-tools`. With those fields absent, `resolved.appimagetool_sha256` etc. are empty, so the verification falls back to `api_sha256` (GitHub's own published digest) with no stale value to conflict with. This works, but it's manual and undocumented, and defeats the point of having a dedicated "move pins forward" command in the first place.
Suggested fix direction
`_pinned_download_fields` (and the `_resolve_appimagetool`/`_resolve_runtime_file` calls it makes) should not verify a freshly-forced download against the old pin still sitting in `resolved` when called from `update_tools()`'s `force_latest=True` path. Either strip the relevant fields from `resolved` before passing it down (mirroring what `init`'s `existing`-based skip logic effectively achieves for a from-scratch project), or thread an explicit "don't verify against the old pin, only against `api_sha256`" flag through to `_resolve_appimagetool`/`_resolve_runtime_file` for this call path.
Summary
`python -m appimage.ctl update-tools` is supposed to move every toolchain pin (Python interpreter, `appimagetool`, runtime stub, the `appimage` module itself) forward to whatever's currently available. Instead it fails with a spurious `sha256 mismatch` error precisely when there's real work to do — i.e. when the pinned upstream artifact has actually changed since the last pin. That's the one case `update-tools` exists to handle, so it currently can't do its job whenever it would matter.
Root cause
In `appimage/ctl/_appimagetool.py`, both `_resolve_appimagetool()` (~line 283) and `_resolve_runtime_file()` (~line 363):
```python
expected = resolved.appimagetool_sha256 or api_sha256
```
`update_tools()` (in `appimage/ctl/update_tools.py`) builds `resolved` from the existing, unmodified `pyproject.toml` config, then calls `_pinned_download_fields(resolved, ..., existing=set(), force_latest=True)`. `existing=set()` tells that function to attempt to resolve every field fresh — but the underlying `resolved` object it passes into `_resolve_appimagetool`/`_resolve_runtime_file` still carries the old, already-configured pin values. Those functions verify the freshly-downloaded file against `resolved.appimagetool_sha256`/`resolved.runtime_sha256` (the old pin) if set, instead of trusting the digest GitHub itself publishes for the asset (`api_sha256`, already fetched for free at no extra cost) and writing that forward.
Net effect: the freshly downloaded (genuinely newer) artifact never matches the stale pin still sitting in `resolved`, so the mismatch check always fires whenever the upstream artifact has actually moved — which is exactly the scenario `update-tools` is meant to handle.
Reproduction
Minimal repro, isolated from any real project:
```toml
pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
[project.scripts]
myapp = "myapp:main"
[tool.hatch.build.targets.wheel]
packages = ["myapp"]
[tool.appimage]
app = "myapp"
entry_point = "myapp"
python = "3.11"
Deliberately stale pin: an appimagetool build that was current a few
days ago, before upstream published a newer build.
appimagetool_version = "appimagetool, continuous build (git version 8c8c91f), build 295 built on 2025-12-04 17:56:36 UTC"
appimagetool_sha256 = "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0"
verify_downloads = true
```
```
$ python -m appimage.ctl update-tools
...
Resolving Python 3.11 download URL...
Downloading appimagetool-x86_64.AppImage
Error: appimagetool sha256 mismatch for .../build/appimagetool-x86_64.AppImage: expected a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0, got ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0. A freshly downloaded file is removed automatically so a retry re-downloads cleanly; a locally configured or already-cached file is left as-is - remove it yourself and retry, or correct the configured hash.
```
Deterministic, not a network fluke — retrying produces the identical error every time, since it always re-downloads the same (genuinely newer) release and compares it against the same stale pin.
Current workaround
Manually delete the stale `_version`/`_sha256` fields (`appimagetool_version`/`appimagetool_sha256`, `runtime_version`/`runtime_sha256`, `appimage_version`/`appimage_sha256`, `appimagectl_version`) from `pyproject.toml` first, then run `enable-reproducible` instead of `update-tools`. With those fields absent, `resolved.appimagetool_sha256` etc. are empty, so the verification falls back to `api_sha256` (GitHub's own published digest) with no stale value to conflict with. This works, but it's manual and undocumented, and defeats the point of having a dedicated "move pins forward" command in the first place.
Suggested fix direction
`_pinned_download_fields` (and the `_resolve_appimagetool`/`_resolve_runtime_file` calls it makes) should not verify a freshly-forced download against the old pin still sitting in `resolved` when called from `update_tools()`'s `force_latest=True` path. Either strip the relevant fields from `resolved` before passing it down (mirroring what `init`'s `existing`-based skip logic effectively achieves for a from-scratch project), or thread an explicit "don't verify against the old pin, only against `api_sha256`" flag through to `_resolve_appimagetool`/`_resolve_runtime_file` for this call path.