Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+mise-archive-age.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Require both Linux mise release archives to age seven days before refreshing the pinned binary.
4 changes: 3 additions & 1 deletion refresh-mise-pin/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 63 additions & 8 deletions tests/refresh_mise_pin/test_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}


Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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": {
Expand All @@ -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"),
],
)
Expand Down
60 changes: 45 additions & 15 deletions tools/refresh_mise_pin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ class _Release(TypedDict):
assets: list[_Asset]


_VERSION: Final[re.Pattern[str]] = re.compile(
r"""
(?P<year>\d{4})\.
(?P<month>\d{1,2})\.
(?P<patch>\d+)
""",
re.VERBOSE,
)


def main() -> None:
parser = argparse.ArgumentParser(description="Refresh a signed mise release pin")
commands = parser.add_subparsers(dest="command", required=True)
Expand Down Expand Up @@ -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:
Expand All @@ -71,22 +85,32 @@ 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:
if workflow.is_absolute() or workflow.parts[:2] != (".github", "workflows") or len(workflow.parts) != 3:
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")
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading