From bfef38fd61f144785052f237d027f050ed2449cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 24 Oct 2026 04:07:21 -0700 Subject: [PATCH] feat(mise): refresh cooled release pin --- README.md | 31 ++ changelog.d/+mise-pin.feature.md | 1 + pylock.toml | 7 +- pyproject.toml | 1 + refresh-mise-pin/action.yml | 70 ++++ tests/refresh_mise_pin/test_refresh.py | 449 +++++++++++++++++++++++++ tools/refresh_mise_pin.py | 201 +++++++++++ 7 files changed, 757 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+mise-pin.feature.md create mode 100644 refresh-mise-pin/action.yml create mode 100644 tests/refresh_mise_pin/test_refresh.py create mode 100644 tools/refresh_mise_pin.py diff --git a/README.md b/README.md index 2a79e47..4941da0 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,37 @@ changes release inputs, including a workspace member's `pyproject.toml` or a sec formatting job runs `prek`; the writer checks patch paths and modes. Its `changed` output is `true` only when the 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. + +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 +mise step in that file. `github-token` needs public release read access. `changed` is `true` only when the two pins +change; `version` omits the leading `v`, and `sha256` is the Linux x64 executable digest. The Action does not update +`mise.lock` or open a PR. + +```yaml + - uses: tox-dev/github-workflows/refresh-mise-pin@SHA + id: mise + with: + workflow: .github/workflows/release.yaml + step-name: 🔧 Set up mise (JDKs) + github-token: ${{ github.token }} + - name: Refresh locked JDKs + run: 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 +writer should run in a separate job; this Action does not request its token. The seven-day cutoff is fixed and cannot be +lowered by an input. + The weekly updater freezes eligible hook revisions with `prek update --freeze --cooldown-days 7`. It then uses `release-creator hooks` to pin Python hook dependencies under the same cutoff and writes a patch when `.pre-commit-config.yaml` changes. The generation job has read-only repository permissions; the separate publish job diff --git a/changelog.d/+mise-pin.feature.md b/changelog.d/+mise-pin.feature.md new file mode 100644 index 0000000..3871c50 --- /dev/null +++ b/changelog.d/+mise-pin.feature.md @@ -0,0 +1 @@ +Add a keyless Action to refresh the signed mise binary pin after its GitHub Release has aged seven days. diff --git a/pylock.toml b/pylock.toml index 8bac29b..81ac6a5 100644 --- a/pylock.toml +++ b/pylock.toml @@ -9,6 +9,7 @@ dependency-groups = [ "lock-reader", "release-notes", "release-resolver", + "release-mise-editor", "release-news", "release-prepare", "release-validate", @@ -3380,7 +3381,7 @@ sha256 = "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8" [[packages]] name = "pyyaml" version = "6.0.3" -marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" +marker = "\"release-hook-check\" in dependency_groups or \"release-hooks\" in dependency_groups or \"release-mise-editor\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"release-shell\" in dependency_groups or \"release-validate\" in dependency_groups or \"test\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] @@ -4846,7 +4847,7 @@ size = 30255 sha256 = "4e8b4e54979b26554c72a7e669009017a7fdd3fb00bbae0b26f82a86636c289f" [tool.release-creator] -created-at = 2026-10-24 09:55:17.406208+00:00 +created-at = 2026-10-24 11:11:49.304279+00:00 [tool.nab] -created-at = 2026-10-24 09:55:17.406208+00:00 +created-at = 2026-10-24 11:11:49.304279+00:00 diff --git a/pyproject.toml b/pyproject.toml index f2e5b39..b2100fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ lock-tools = [ "nab==0.0.22", "tomlkit>=0.13" ] lock-reader = [ "certifi>=2026.7.22", "packaging>=26.3" ] release-notes = [ "release-creator>=1.1.0" ] release-resolver = [ "uv==0.13.5" ] +release-mise-editor = [ "pyyaml>=6" ] release-news = [ "release-creator>=1.1.0", "towncrier>=26.9" ] release-prepare = [ "release-creator>=1.1.0", "towncrier>=26.9", "prek>=0.2" ] release-validate = [ "release-creator>=1.1.0", "prek>=0.2" ] diff --git a/refresh-mise-pin/action.yml b/refresh-mise-pin/action.yml new file mode 100644 index 0000000..df831a2 --- /dev/null +++ b/refresh-mise-pin/action.yml @@ -0,0 +1,70 @@ +name: Refresh mise release pin +description: Update a release workflow to a seven-day-old, signature-verified mise binary +inputs: + workflow: + description: Repository-relative workflow containing the pinned mise step + required: true + step-name: + description: Exact name of the mise setup step in that workflow + required: true + github-token: + description: Token with read access to public GitHub releases + required: true +outputs: + changed: + description: Whether the workflow's mise version or digest changed + value: ${{ steps.update.outputs.changed }} + version: + description: Selected mise version without the v prefix + value: ${{ steps.update.outputs.version }} + sha256: + description: SHA256 of the verified Linux x64 mise executable + value: ${{ steps.update.outputs.sha256 }} +runs: + using: composite + steps: + - name: List upstream releases + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + 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' \ + 'repos/jdx/mise/releases?per_page=100' >"$RUNNER_TEMP/mise-releases.jsonl" + - name: Install locked YAML editor + uses: $/install-locked-tools + with: + groups: release-mise-editor + venv: ${{ runner.temp }}/mise-editor + lock-source: shared + - name: Select seven-day-old release + id: select + shell: bash + run: | + uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \ + "$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" select \ + "$RUNNER_TEMP/mise-releases.jsonl" "$GITHUB_OUTPUT" + - name: Install signature-verified mise + uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2 + with: + version: ${{ steps.select.outputs.version }} + mise_dir: ${{ runner.temp }}/refresh-mise-pin + cache: "false" + install: "false" + env: "false" + export_path: "false" + add_shims_to_path: "false" + - name: Update the workflow pin + id: update + shell: bash + env: + WORKFLOW: ${{ inputs.workflow }} + STEP_NAME: ${{ inputs.step-name }} + VERSION: ${{ steps.select.outputs.version }} + EXPECTED_SHA256: ${{ steps.select.outputs.sha256 }} + run: | + uv run --no-project --python "$RUNNER_TEMP/mise-editor" python \ + "$GITHUB_ACTION_PATH/../tools/refresh_mise_pin.py" update \ + "$WORKFLOW" "$STEP_NAME" "$VERSION" "$EXPECTED_SHA256" \ + "$RUNNER_TEMP/refresh-mise-pin/bin/mise" "$GITHUB_OUTPUT" diff --git a/tests/refresh_mise_pin/test_refresh.py b/tests/refresh_mise_pin/test_refresh.py new file mode 100644 index 0000000..cb5ef9d --- /dev/null +++ b/tests/refresh_mise_pin/test_refresh.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from pathlib import Path + +import pytest +import yaml + +from tools.refresh_mise_pin import main + + +def _release( + version: str, + *, + days: int, + prerelease: bool = False, + draft: bool = False, + asset: bool = True, + digest: str = "sha256:" + "a" * 64, + asset_days: int | None = None, +) -> dict[str, object]: + asset_date = (datetime.now(UTC) - timedelta(days=days if asset_days is None else asset_days)).isoformat() + 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 [], + } + + +def _step(version: str, digest: str) -> str: + return ( + " - name: 🔧 Set up mise (JDKs)\n" + " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" + " with:\n" + f" version: {version}\n" + f" sha256: {digest}\n" + ) + + +@pytest.mark.parametrize( + ("workflow_text", "old_version"), + [ + pytest.param( + "jobs:\n build:\n steps:\n - name: 🔧 Set up mise (JDKs)\n" + " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" + ' with:\n version: "2026.10.12"\n sha256: ' + + "0" * 64 + + "\n - name: Build\n run: echo unchanged\n", + 'version: "2026.10.13"', + id="jetbrains-fish", + ), + pytest.param( + "jobs:\n build:\n steps:\n - name: 🔧 Set up mise (JDKs)\n" + " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8 # v5.1.2\n" + " with:\n version: 2026.10.12\n sha256: " + + "0" * 64 + + "\n - name: Build\n run: echo unchanged\n", + "version: 2026.10.13", + id="pyvenvmanage", + ), + ], +) +def test_update_real_workflow_styles( + tmp_path: Path, workflow_text: str, old_version: str, mise_binary: tuple[Path, str] +) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, workflow_text) + result = _run( + tmp_path, + "update", + str(workflow.relative_to(tmp_path)), + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode == 0, result.stderr + assert old_version in workflow.read_text() + assert f"sha256: {digest}" in workflow.read_text() + assert "run: echo unchanged" in workflow.read_text() + assert (tmp_path / "output").read_text() == f"changed=true\nversion=2026.10.13\nsha256={digest}\n" + + +def test_update_does_not_rewrite_unchanged_pin(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, _step("2026.10.13", digest)) + before = workflow.stat().st_mtime_ns + result = _run( + tmp_path, + "update", + str(workflow.relative_to(tmp_path)), + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode == 0, result.stderr + assert workflow.stat().st_mtime_ns == before + assert (tmp_path / "output").read_text().startswith("changed=false\n") + + +def test_update_rejects_downgrade(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, _step("2026.10.14", "0" * 64)) + result = _run( + tmp_path, + "update", + ".github/workflows/release.yaml", + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode != 0 + assert "older than the current pin" in result.stderr + assert "version: 2026.10.14" in workflow.read_text() + + +def test_update_preserves_following_step_fields(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, _step("2026.10.12", "0" * 64) + " env:\n OTHER: yes\n") + result = _run( + tmp_path, + "update", + ".github/workflows/release.yaml", + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode == 0, result.stderr + assert " env:\n OTHER: yes\n" in workflow.read_text() + + +def test_update_ignores_step_text_inside_run_block(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + fake = _step("2026.10.12", "0" * 64) + content = ( + "jobs:\n build:\n steps:\n - name: Write fixture\n run: |\n" + + "".join(f" {line}" for line in fake.splitlines(keepends=True)) + + _step("2026.10.12", "0" * 64) + ) + workflow = _workflow(tmp_path, content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode == 0, result.stderr + assert workflow.read_text().count("version: 2026.10.12") == 1 + assert workflow.read_text().count("version: 2026.10.13") == 1 + + +def test_update_rejects_aliased_inputs_from_other_step(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + content = ( + "jobs:\n build:\n steps:\n" + " - name: Other\n uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" + f" with: &mise\n version: 2026.10.12\n sha256: {'0' * 64}\n" + " - name: 🔧 Set up mise (JDKs)\n" + " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" + " with: *mise\n" + ) + workflow = _workflow(tmp_path, content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode != 0 + assert "YAML aliases outside" in result.stderr + assert workflow.read_text() == content + + +def test_update_rejects_inputs_aliased_by_later_step(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + content = ( + "jobs:\n build:\n steps:\n" + " - name: 🔧 Set up mise (JDKs)\n" + " uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" + f" with: &mise\n version: 2026.10.12\n sha256: {'0' * 64}\n" + " - name: Other\n uses: jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8\n" + " with: *mise\n" + ) + workflow = _workflow(tmp_path, content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode != 0 + assert "aliased elsewhere" in result.stderr + assert workflow.read_text() == content + + +def test_update_rejects_shadowed_job(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + content = "jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64) + " build:\n steps: []\n" + workflow = _workflow(tmp_path, content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode != 0 + assert "duplicate YAML job name" in result.stderr + assert workflow.read_text() == content + + +@pytest.mark.parametrize("content", ["[]\n", "jobs: []\n"], ids=["root", "jobs"]) +def test_update_rejects_missing_jobs_mapping(tmp_path: Path, mise_binary: tuple[Path, str], content: str) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, _step("2026.10.12", "0" * 64)) + workflow.write_text(content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode != 0 + assert "workflow needs a jobs mapping" in result.stderr + + +def test_update_handles_unrelated_recursive_alias(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + content = "jobs:\n build:\n steps:\n - name: Other\n env: &loop [*loop]\n" + _step( + "2026.10.12", "0" * 64 + ) + workflow = _workflow(tmp_path, content) + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode == 0, result.stderr + assert "version: 2026.10.13" in workflow.read_text() + + +@pytest.mark.parametrize( + ("content", "message"), + [ + pytest.param("jobs: {}\n", "found 0", id="missing-step"), + pytest.param( + "jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64) * 2, "found 2", id="duplicate-step" + ), + pytest.param( + _step("2026.10.12", "0" * 64).replace("jdx/mise-action@08", "jdx/mise-action@v5."), + "SHA-pinned", + id="mutable-action", + ), + pytest.param(_step("2026.10.12", "0" * 64).split(" with:\n")[0], "with mapping", id="missing-with"), + pytest.param( + _step("2026.10.12", "0" * 64).replace(" sha256: " + "0" * 64, ""), + "literal version and sha256", + id="missing-digest", + ), + pytest.param( + _step("2026.10.12", "0" * 64).replace(" version: 2026.10.12", " version: latest"), + "literal version and sha256", + id="floating-version", + ), + pytest.param( + _step("2026.10.12", "0" * 64).replace( + " sha256: " + "0" * 64, " sha256: " + "0" * 64 + "\n sha256: " + "1" * 64 + ), + "duplicate YAML key: sha256", + id="duplicate-digest", + ), + ], +) +def test_update_rejects_ambiguous_workflow( + tmp_path: Path, mise_binary: tuple[Path, str], content: str, message: str +) -> None: + binary, digest = mise_binary + workflow = _workflow(tmp_path, content) + original = workflow.read_text() + result = _run( + tmp_path, + "update", + str(workflow.relative_to(tmp_path)), + "🔧 Set up mise (JDKs)", + "2026.10.13", + digest, + str(binary), + ) + assert result.returncode != 0 + assert message in result.stderr + assert workflow.read_text() == original + + +@pytest.mark.parametrize( + ("workflow_name", "version", "digest", "binary_version", "message"), + [ + pytest.param("README.md", "2026.10.13", "a" * 64, "2026.10.13", "workflow must", id="outside-workflows"), + pytest.param( + ".github/workflows/release.yaml", + "latest", + "a" * 64, + "2026.10.13", + "invalid mise version", + id="invalid-version", + ), + pytest.param( + ".github/workflows/release.yaml", + "2026.10.13", + "bad", + "2026.10.13", + "invalid mise digest", + id="invalid-digest", + ), + pytest.param( + ".github/workflows/release.yaml", + "2026.10.13", + "a" * 64, + "2026.10.12", + "did not install", + id="wrong-binary-version", + ), + pytest.param( + ".github/workflows/release.yaml", "2026.10.13", "0" * 64, "2026.10.13", "differs", id="wrong-binary-digest" + ), + ], +) +def test_update_rejects_unverified_inputs( + tmp_path: Path, workflow_name: str, version: str, digest: str, binary_version: str, message: str +) -> None: + workflow = tmp_path / workflow_name + workflow.parent.mkdir(parents=True, exist_ok=True) + workflow.write_text("jobs:\n build:\n steps:\n" + _step("2026.10.12", "0" * 64)) + binary = tmp_path / "bin" / "mise" + binary.parent.mkdir() + binary.write_bytes(b"verified mise") + binary.with_name("mise-version").write_text(binary_version) + result = _run(tmp_path, "update", workflow_name, "🔧 Set up mise (JDKs)", version, digest, str(binary)) + assert result.returncode != 0 + assert message in result.stderr + + +def test_update_rejects_symlinked_workflow(tmp_path: Path, mise_binary: tuple[Path, str]) -> None: + binary, digest = mise_binary + target = _workflow(tmp_path, _step("2026.10.12", "0" * 64)) + target.rename(target.with_name("actual.yaml")) + target.symlink_to("actual.yaml") + result = _run( + tmp_path, "update", ".github/workflows/release.yaml", "🔧 Set up mise (JDKs)", "2026.10.13", digest, str(binary) + ) + assert result.returncode != 0 + assert "regular YAML" in result.stderr + + +def test_select_newest_stable_aged_release(tmp_path: Path) -> None: + releases = [ + _release("2026.10.15", days=1), + _release("2026.10.14", days=8, prerelease=True), + _release("2026.10.13", days=8), + _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.13\nsha256={'a' * 64}\n" + + +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 + + +def test_select_uses_older_release_when_newer_asset_is_recent(tmp_path: Path) -> None: + releases = [_release("2026.10.13", days=8, asset_days=1), _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( + sys, "argv", ["refresh_mise_pin.py", "select", str(tmp_path / "releases.jsonl"), str(tmp_path / "output")] + ) + main() + assert (tmp_path / "output").read_text() == f"version=2026.10.13\nsha256={'a' * 64}\n" + + +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 == { + "name": "Install signature-verified mise", + "uses": "jdx/mise-action@08ae2a091c038e1e05ec6f5115dc5c69d5a2a6b8", + "with": { + "version": "${{ steps.select.outputs.version }}", + "mise_dir": "${{ runner.temp }}/refresh-mise-pin", + "cache": "false", + "install": "false", + "env": "false", + "export_path": "false", + "add_shims_to_path": "false", + }, + } + + +@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, digest="sha256:bad")], "no unique Linux", id="bad-asset-digest"), + ], +) +def test_select_rejects_invalid_release(tmp_path: Path, releases: list[dict[str, object]], message: str) -> None: + (tmp_path / "releases.jsonl").write_text("\n".join(map(json.dumps, releases))) + result = _run(tmp_path, "select", "releases.jsonl") + assert result.returncode != 0 + assert message in result.stderr + + +@pytest.fixture +def mise_binary(tmp_path: Path) -> tuple[Path, str]: + binary = tmp_path / "bin" / "mise" + binary.parent.mkdir() + binary.write_bytes(b"verified mise") + binary.with_name("mise-version").write_text("2026.10.13") + return binary, sha256(binary.read_bytes()).hexdigest() + + +def _workflow(tmp_path: Path, content: str) -> Path: + workflow = tmp_path / ".github" / "workflows" / "release.yaml" + workflow.parent.mkdir(parents=True) + workflow.write_text(content if content.startswith("jobs:") else "jobs:\n build:\n steps:\n" + content) + return workflow + + +def _run(tmp_path: Path, command: str, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[2] / "tools" / "refresh_mise_pin.py"), + command, + *arguments, + str(tmp_path / "output"), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) diff --git a/tools/refresh_mise_pin.py b/tools/refresh_mise_pin.py new file mode 100644 index 0000000..75fc269 --- /dev/null +++ b/tools/refresh_mise_pin.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import tempfile +from datetime import UTC, datetime, timedelta +from hashlib import file_digest +from pathlib import Path +from typing import Final, TypedDict, cast + +import yaml +from yaml.error import Mark +from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode + + +class _Asset(TypedDict): + name: str + digest: str + created_at: str + updated_at: str + + +class _Release(TypedDict): + tag_name: str + published_at: str + draft: bool + prerelease: bool + assets: list[_Asset] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Refresh a signed mise release pin") + commands = parser.add_subparsers(dest="command", required=True) + select = commands.add_parser("select") + select.add_argument("releases", type=Path) + select.add_argument("output", type=Path) + update = commands.add_parser("update") + update.add_argument("workflow", type=Path) + update.add_argument("step_name") + update.add_argument("version") + update.add_argument("expected_sha256") + update.add_argument("binary", type=Path) + update.add_argument("output", type=Path) + args = parser.parse_args() + try: + if args.command == "select": + version, digest = _select_release(args.releases) + _write_outputs(args.output, version=version, sha256=digest) + else: + changed = _update_pin(args.workflow, args.step_name, args.version, args.expected_sha256, args.binary) + _write_outputs(args.output, changed=str(changed).lower(), version=args.version, sha256=args.expected_sha256) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError, yaml.YAMLError) as error: + parser.error(str(error)) + + +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"])): + continue + published = datetime.fromisoformat(release["published_at"]) + if published <= cutoff: + eligible.append((tuple(map(int, version.groups())), release)) + if not eligible: + 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")): + return version, assets[0]["digest"].removeprefix("sha256:") + raise ValueError("no mise executable was 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): + raise ValueError("invalid mise version") + if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256): + 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") + with binary.open("rb") as stream: + if file_digest(stream, "sha256").hexdigest() != expected_sha256: + raise ValueError("verified mise binary differs from the GitHub release asset digest") + source = workflow.read_text() + root = yaml.compose(source) + if not isinstance(root, MappingNode): + raise TypeError("workflow needs a jobs mapping") + jobs = _value(root, "jobs") + if not isinstance(jobs, MappingNode): + raise TypeError("workflow needs a jobs mapping") + names = [name.value for name, _ in jobs.value if isinstance(name, ScalarNode)] + if len(names) != len(set(names)): + raise ValueError("duplicate YAML job name") + matches = [ + step + for _, job in jobs.value + if isinstance(job, MappingNode) and isinstance(steps := _value(job, "steps"), SequenceNode) + for step in steps.value + if isinstance(step, MappingNode) + and isinstance(name := _value(step, "name"), ScalarNode) + and name.value == step_name + ] + if len(matches) != 1: + 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): + raise ValueError("selected step must use one SHA-pinned jdx/mise-action") + inputs = _value(step, "with") + if not isinstance(inputs, MappingNode): + raise TypeError("selected step must contain one with mapping") + values = {key: _value(inputs, key) for key in ("version", "sha256")} + step_start, step_end = _span(step) + if any( + not (step_start <= _span(node)[0] < _span(node)[1] <= step_end) + for node in (inputs, *values.values()) + if node is not None + ): + 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 + ) + ): + raise ValueError("selected mise step needs one literal version and sha256") + current = cast(ScalarNode, values["version"]).value + if tuple(map(int, current.split("."))) > tuple(map(int, version.split("."))): + raise ValueError(f"selected mise {version} is older than the current pin {current}") + updated = source + for key, value in sorted( + (("version", version), ("sha256", expected_sha256)), + key=lambda item: _span(cast(ScalarNode, values[item[0]]))[0], + reverse=True, + ): + node = cast(ScalarNode, values[key]) + literal = f"{node.style}{value}{node.style}" if node.style else value + start, end = _span(node) + updated = f"{updated[:start]}{literal}{updated[end:]}" + if updated == source: + return False + descriptor, name = tempfile.mkstemp(dir=workflow.parent, prefix=f".{workflow.name}.") + try: + with os.fdopen(descriptor, "w") as stream: + stream.write(updated) + os.chmod(name, stat.S_IMODE(workflow.stat().st_mode)) + os.replace(name, workflow) + finally: + Path(name).unlink(missing_ok=True) + return True + + +def _value(mapping: MappingNode, key: str) -> Node | None: + matches = [value for name, value in mapping.value if isinstance(name, ScalarNode) and name.value == key] + if len(matches) > 1: + raise ValueError(f"duplicate YAML key: {key}") + return cast(Node, matches[0]) if matches else None + + +def _span(node: Node) -> tuple[int, int]: + return cast(Mark, node.start_mark).index, cast(Mark, node.end_mark).index + + +def _occurrences(root: Node, target: Node, ancestors: frozenset[int] = frozenset()) -> int: + count = int(root is target) + if id(root) in ancestors: + return count + if isinstance(root, MappingNode): + children = (node for pair in root.value for node in pair) + elif isinstance(root, SequenceNode): + children = iter(root.value) + else: + return count + return count + sum(_occurrences(node, target, ancestors | {id(root)}) for node in children) + + +def _write_outputs(path: Path, **values: str) -> None: + with path.open("a") as stream: + for name, value in values.items(): + stream.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() + + +__all__ = ["main"]