From 6f0fbcad623f232db12cfc4fbd781081f576c414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 05:09:50 -0700 Subject: [PATCH] fix(mise): cool release archives --- README.md | 16 +++--- changelog.d/+mise-archive-age.bugfix.md | 1 + refresh-mise-pin/action.yml | 4 +- tests/refresh_mise_pin/test_refresh.py | 71 ++++++++++++++++++++++--- tools/refresh_mise_pin.py | 60 +++++++++++++++------ 5 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 changelog.d/+mise-archive-age.bugfix.md diff --git a/README.md b/README.md index 4941da0..16f0693 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,13 @@ refresh produces a patch. Neither mode pushes a branch or requests a write token ## Refresh a pinned mise binary `refresh-mise-pin` updates one named `jdx/mise-action` step in a release workflow. It selects the newest stable mise -GitHub Release whose publication and Linux executable upload are at least seven days old. The executable must also be -unchanged for seven days. The Action installs that version through the pinned mise Action and compares the executable -with the release asset digest. The mise Action verifies the download against mise's minisign-signed checksum file. The -refresh Action fails if the named step is missing or duplicated, its version or digest is not literal, or the installed -binary differs from the selected release. It changes only that step's `version` and `sha256` values and leaves the -verified mise executable on `PATH` for the next step. +GitHub Release whose publication and Linux executable and archive uploads are at least seven days old. Those assets must +also be unchanged for seven days. The Action installs that version through the pinned mise Action and compares the +executable with the release asset digest. Before invoking mise, the mise Action verifies its downloaded archive against +the minisign-signed checksum file. The refresh Action fails if the named step is missing or duplicated, its version or +digest is not literal, or the installed binary differs from the selected release. It changes only that step's `version` +and `sha256` values. The verified mise executable is on `PATH` and at `$RUNNER_TEMP/refresh-mise-pin/bin/mise` for the +next step; `export_path: false` disables mise-generated paths, not the binary's path. Call it in a read-only Ubuntu job after checkout and setup-uv with Python 3.15. The Action installs its YAML parser from the shared hash-checked lock. `workflow` is the path under `.github/workflows`; `step-name` is the exact name of the @@ -176,7 +177,8 @@ change; `version` omits the leading `v`, and `sha256` is the Linux x64 executabl step-name: 🔧 Set up mise (JDKs) github-token: ${{ github.token }} - name: Refresh locked JDKs - run: mise lock --bump --minimum-release-age 7d + run: >- + $RUNNER_TEMP/refresh-mise-pin/bin/mise lock --bump --minimum-release-age 7d ``` Include `.github/workflows/release.yaml` and `mise.lock` in the updater's artifact and App writer path allowlists. The diff --git a/changelog.d/+mise-archive-age.bugfix.md b/changelog.d/+mise-archive-age.bugfix.md new file mode 100644 index 0000000..97d042b --- /dev/null +++ b/changelog.d/+mise-archive-age.bugfix.md @@ -0,0 +1 @@ +Require both Linux mise release archives to age seven days before refreshing the pinned binary. diff --git a/refresh-mise-pin/action.yml b/refresh-mise-pin/action.yml index df831a2..9a9e352 100644 --- a/refresh-mise-pin/action.yml +++ b/refresh-mise-pin/action.yml @@ -30,7 +30,9 @@ runs: run: | gh api --paginate \ --jq '.[] | {tag_name,published_at,prerelease,draft,assets:[.assets[] | - select(.name | endswith("-linux-x64")) | {name,digest,created_at,updated_at}]} | @json' \ + select(.name | endswith("-linux-x64") or endswith("-linux-x64.tar.zst") or + endswith("-linux-x64.tar.gz")) | + {name,digest,created_at,updated_at}]} | @json' \ 'repos/jdx/mise/releases?per_page=100' >"$RUNNER_TEMP/mise-releases.jsonl" - name: Install locked YAML editor uses: $/install-locked-tools diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py index cb5ef9d..1cada0c 100644 --- a/tests/refresh_mise_pin/test_refresh.py +++ b/tests/refresh_mise_pin/test_refresh.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta from hashlib import sha256 from pathlib import Path +from typing import cast import pytest import yaml @@ -22,18 +23,32 @@ def _release( asset: bool = True, digest: str = "sha256:" + "a" * 64, asset_days: int | None = None, + archive: bool = True, + recent_archive: str = "", ) -> dict[str, object]: asset_date = (datetime.now(UTC) - timedelta(days=days if asset_days is None else asset_days)).isoformat() + archive_date = (datetime.now(UTC) - timedelta(days=days)).isoformat() + assets = ( + [{"name": f"mise-v{version}-linux-x64", "digest": digest, "created_at": asset_date, "updated_at": asset_date}] + if asset + else [] + ) + if archive: + assets.extend( + { + "name": f"mise-v{version}-linux-x64{suffix}", + "digest": "sha256:" + "b" * 64, + "created_at": archive_date, + "updated_at": datetime.now(UTC).isoformat() if suffix == recent_archive else archive_date, + } + for suffix in (".tar.zst", ".tar.gz") + ) return { "tag_name": f"v{version}", "published_at": (datetime.now(UTC) - timedelta(days=days)).isoformat(), "prerelease": prerelease, "draft": draft, - "assets": [ - {"name": f"mise-v{version}-linux-x64", "digest": digest, "created_at": asset_date, "updated_at": asset_date} - ] - if asset - else [], + "assets": assets, } @@ -363,7 +378,7 @@ def test_select_rejects_recent_asset_on_aged_release(tmp_path: Path) -> None: (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8, asset_days=1))) result = _run(tmp_path, "select", "releases.jsonl") assert result.returncode != 0 - assert "no mise executable was uploaded and unchanged" in result.stderr + assert "no mise executable and archives were uploaded and unchanged" in result.stderr def test_select_uses_older_release_when_newer_asset_is_recent(tmp_path: Path) -> None: @@ -374,6 +389,22 @@ def test_select_uses_older_release_when_newer_asset_is_recent(tmp_path: Path) -> assert (tmp_path / "output").read_text() == f"version=2026.10.12\nsha256={'a' * 64}\n" +@pytest.mark.parametrize("suffix", [pytest.param(".tar.zst", id="zstd"), pytest.param(".tar.gz", id="gzip")]) +def test_select_rejects_recent_archive_on_aged_release(tmp_path: Path, suffix: str) -> None: + (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8, recent_archive=suffix))) + result = _run(tmp_path, "select", "releases.jsonl") + assert result.returncode != 0 + assert "no mise executable and archives were uploaded and unchanged" in result.stderr + + +def test_select_uses_older_release_when_newer_archive_is_recent(tmp_path: Path) -> None: + releases = [_release("2026.10.13", days=8, recent_archive=".tar.zst"), _release("2026.10.12", days=9)] + (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) + result = _run(tmp_path, "select", "releases.jsonl") + assert result.returncode == 0, result.stderr + assert (tmp_path / "output").read_text() == f"version=2026.10.12\nsha256={'a' * 64}\n" + + def test_main_selects_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: (tmp_path / "releases.jsonl").write_text(json.dumps(_release("2026.10.13", days=8))) monkeypatch.setattr( @@ -385,8 +416,14 @@ def test_main_selects_release(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) - def test_action_verifies_signed_checksum_and_exports_mise_path() -> None: action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text()) - installer = next(step for step in action["runs"]["steps"] if step.get("uses", "").startswith("jdx/mise-action@")) - assert installer == { + steps = action["runs"]["steps"] + installer_index = next( + index for index, step in enumerate(steps) if step.get("uses", "").startswith("jdx/mise-action@") + ) + assert installer_index < next( + index for index, step in enumerate(steps) if step["name"] == "Update the workflow pin" + ) + assert steps[installer_index] == { "name": "Install signature-verified mise", "uses": "jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8", "with": { @@ -401,12 +438,30 @@ def test_action_verifies_signed_checksum_and_exports_mise_path() -> None: } +def test_action_keeps_linux_executable_and_archives_in_release_listing() -> None: + action = yaml.safe_load((Path(__file__).resolve().parents[2] / "refresh-mise-pin" / "action.yml").read_text()) + query = action["runs"]["steps"][0]["run"].split("--jq '", 1)[1].split("'", 1)[0] + release = _release("2026.10.13", days=8) + cast(list[dict[str, object]], release["assets"]).append( + {"name": "mise-v2026.10.13-linux-x64-musl", "digest": "sha256:" + "c" * 64} + ) + result = subprocess.run( + ["jq", "-r", query], input=json.dumps([release]), text=True, capture_output=True, check=True + ) + assert {asset["name"] for asset in json.loads(result.stdout)["assets"]} == { + "mise-v2026.10.13-linux-x64", + "mise-v2026.10.13-linux-x64.tar.zst", + "mise-v2026.10.13-linux-x64.tar.gz", + } + + @pytest.mark.parametrize( ("releases", "message"), [ pytest.param([_release("2026.10.13", days=1)], "no stable mise", id="too-young"), pytest.param([_release("2026.10.13", days=8, draft=True)], "no stable mise", id="draft"), pytest.param([_release("2026.10.13", days=8, asset=False)], "no unique Linux", id="missing-asset"), + pytest.param([_release("2026.10.13", days=8, archive=False)], "no unique Linux", id="missing-archive"), pytest.param([_release("2026.10.13", days=8, digest="sha256:bad")], "no unique Linux", id="bad-asset-digest"), ], ) diff --git a/tools/refresh_mise_pin.py b/tools/refresh_mise_pin.py index 75fc269..f02527e 100644 --- a/tools/refresh_mise_pin.py +++ b/tools/refresh_mise_pin.py @@ -31,6 +31,16 @@ class _Release(TypedDict): assets: list[_Asset] +_VERSION: Final[re.Pattern[str]] = re.compile( + r""" + (?P\d{4})\. + (?P\d{1,2})\. + (?P\d+) + """, + re.VERBOSE, +) + + def main() -> None: parser = argparse.ArgumentParser(description="Refresh a signed mise release pin") commands = parser.add_subparsers(dest="command", required=True) @@ -58,11 +68,15 @@ def main() -> None: def _select_release(path: Path) -> tuple[str, str]: cutoff = datetime.now(UTC) - timedelta(days=7) - version_pattern: Final[re.Pattern[str]] = re.compile(r"v(\d{4})\.(\d{1,2})\.(\d+)") releases = (cast(_Release, json.loads(line)) for line in path.read_text().splitlines()) eligible: list[tuple[tuple[int, ...], _Release]] = [] for release in releases: - if release["draft"] or release["prerelease"] or not (version := version_pattern.fullmatch(release["tag_name"])): + if ( + release["draft"] + or release["prerelease"] + or not release["tag_name"].startswith("v") + or not (version := _VERSION.fullmatch(release["tag_name"][1:])) + ): continue published = datetime.fromisoformat(release["published_at"]) if published <= cutoff: @@ -71,12 +85,22 @@ def _select_release(path: Path) -> tuple[str, str]: raise ValueError("no stable mise release was published at least seven days ago") for _, release in sorted(eligible, key=lambda item: item[0], reverse=True): version = release["tag_name"][1:] - assets = [asset for asset in release["assets"] if asset["name"] == f"mise-v{version}-linux-x64"] - if len(assets) != 1 or not re.fullmatch(r"sha256:[a-f0-9]{64}", assets[0]["digest"]): - raise ValueError(f"mise {version} has no unique Linux x64 executable digest") - if all(datetime.fromisoformat(assets[0][key]) <= cutoff for key in ("created_at", "updated_at")): + assets: list[_Asset] = [] + for suffix in ("", ".tar.zst", ".tar.gz"): + name = f"mise-v{version}-linux-x64{suffix}" + matches = [asset for asset in release["assets"] if asset["name"] == name] + if ( + len(matches) != 1 + or not matches[0]["digest"].startswith("sha256:") + or not _hex(matches[0]["digest"][7:], 64) + ): + raise ValueError(f"mise {version} has no unique Linux x64 asset digest for {name}") + assets.append(matches[0]) + if all( + datetime.fromisoformat(asset[key]) <= cutoff for asset in assets for key in ("created_at", "updated_at") + ): return version, assets[0]["digest"].removeprefix("sha256:") - raise ValueError("no mise executable was uploaded and unchanged for at least seven days") + raise ValueError("no mise executable and archives were uploaded and unchanged for at least seven days") def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: str, binary: Path) -> bool: @@ -84,9 +108,9 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s raise ValueError("workflow must be a file in .github/workflows") if workflow.suffix not in {".yaml", ".yml"} or workflow.is_symlink(): raise ValueError("workflow must be a regular YAML file") - if not re.fullmatch(r"\d{4}\.\d{1,2}\.\d+", version): + if not _VERSION.fullmatch(version): raise ValueError("invalid mise version") - if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256): + if not _hex(expected_sha256, 64): raise ValueError("invalid mise digest") if binary.is_symlink() or not binary.is_file() or binary.with_name("mise-version").read_text().strip() != version: raise ValueError("mise-action did not install the selected version") @@ -116,7 +140,11 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s raise ValueError(f"expected one mise step named {step_name!r}; found {len(matches)}") step = matches[0] uses = _value(step, "uses") - if not isinstance(uses, ScalarNode) or not re.fullmatch(r"jdx/mise-action@[a-f0-9]{40}", uses.value): + if ( + not isinstance(uses, ScalarNode) + or not uses.value.startswith("jdx/mise-action@") + or not _hex(uses.value.removeprefix("jdx/mise-action@"), 40) + ): raise ValueError("selected step must use one SHA-pinned jdx/mise-action") inputs = _value(step, "with") if not isinstance(inputs, MappingNode): @@ -131,11 +159,9 @@ def _update_pin(workflow: Path, step_name: str, version: str, expected_sha256: s raise ValueError("selected mise step uses YAML aliases outside its own mapping") if any(_occurrences(root, node) != 1 for node in (inputs, *values.values()) if node is not None): raise ValueError("selected mise inputs are aliased elsewhere in the workflow") - if not all( - isinstance(node, ScalarNode) and node.style in (None, "'", '"') and re.fullmatch(pattern, node.value) - for (key, pattern), node in zip( - (("version", r"\d{4}\.\d{1,2}\.\d+"), ("sha256", r"[a-f0-9]{64}")), values.values(), strict=True - ) + if not all(isinstance(node, ScalarNode) and node.style in (None, "'", '"') for node in values.values()) or not ( + _VERSION.fullmatch(cast(ScalarNode, values["version"]).value) + and _hex(cast(ScalarNode, values["sha256"]).value, 64) ): raise ValueError("selected mise step needs one literal version and sha256") current = cast(ScalarNode, values["version"]).value @@ -171,6 +197,10 @@ def _value(mapping: MappingNode, key: str) -> Node | None: return cast(Node, matches[0]) if matches else None +def _hex(value: str, length: int) -> bool: + return len(value) == length and all(character in "0123456789abcdef" for character in value) + + def _span(node: Node) -> tuple[int, int]: return cast(Mark, node.start_mark).index, cast(Mark, node.end_mark).index