gabriel / muse public
feat BREAKING feat/57-version-tag-sign-and-remote-list #1 / 1
AI Agent gabriel · 11 hours ago · Oct 21, 2026 · Diff

feat: version tag signing (muse tag add --sign / tag verify) + remote listing (muse#57 items 3-4)

Item 3: cryptographic signing of version tags.

Added signer_public_key/signature fields to VersionTagRecord (empty-string defaults, backward-compatible with every existing unsigned on-disk tag). version_tag_payload(repo_id, tag, commit_id) computes the canonical SHA-256 signing payload -- null-byte-separated, mirroring muse.core.provenance.provenance_payload's existing pattern for signed commits. author/message/created_at are deliberately excluded from the payload: they're metadata, not what "this tag was created by an authorized identity" needs to vouch for, so correcting a typo in the message doesn't invalidate an existing signature.

sign_version_tag()/verify_version_tag() reuse muse.core.provenance's existing sign_commit_ed25519/verify_commit_ed25519 primitives directly -- despite the "commit" in their names, they're payload-agnostic (sign/verify an arbitrary string), so no new crypto code was needed, just a new payload shape.

`muse tag add --sign` resolves a signing identity via get_signing_identity(root) (no --hub needed -- this is a purely local operation, and get_signing_identity already falls back to the repo-local [hub] url from .muse/config.toml internally) and signs (repo_id, tag, commit_id). Warns on stderr (not crash) when no identity is available, matching the muse#75 pattern. New `muse tag verify <tag>` subcommand recomputes the payload and checks it against the stored signature/public key -- exit 0 only when genuinely valid.

Item 4: `muse tag list --remote <name> [--pre]` queries a remote's version tags via the ALREADY-EXISTING `GET /{owner}/{slug}/version-tags` endpoint (confirmed live in musehub/api/routes/wire.py -- the ticket's "requires a new hub API endpoint" was already satisfied) and the already-existing transport.fetch_version_tags() client method (already used by `muse pull`, just never exposed as a read-only, no-pull-required CLI surface). Read-only: does not write to .muse/version-tags/. Reuses VersionTagRecord.from_dict() to convert the wire dicts (which already has a parse_semver(tag_name) fallback for the GET endpoint's flat semver_major/minor/etc shape, vs push's nested "semver" dict -- both shapes already existed independently in production; this uses the existing fallback, doesn't change it).

Also extended tag push's wire dict and pull's receiving reconstruction to carry signer_public_key/signature, so a signed tag survives a real push/pull round-trip, not just local storage.

New tests: - tests/test_version_tag_signing.py (13): payload determinism, sign/verify round-trip, tampered-commit-id detection (the entire point of binding commit_id into the payload), wrong-key detection, malformed-key handling (returns False, never raises), VersionTagRecord round-trip including backward-compat defaults for legacy on-disk tags with no signature keys at all. - tests/test_cmd_tag_sign.py (7): real keygen/register flow (network calls mocked, matching test_mist_cli.py's TestMistSignHubContext pattern) -- --sign produces signed:true, default is unsigned, no-identity warns without crashing, tag verify on valid/unsigned/tampered/nonexistent tags. - tests/test_cmd_tag_list_remote.py (7): lists from a mocked remote transport without touching local state, --pre filtering applies to remote results too, sorted by semver precedence, unconfigured remote errors cleanly, signed remote tags report signed:true.

Confirmed genuine RED on all three new test files (14 total failures: unknown --sign flag, missing verify subcommand, missing --remote flag, missing signed/signer_public_key keys) before GREEN with the fix restored.

Live-verified the full cross-repo round trip against the real local musehub instance (migration applied, both containers restarted, clean boot confirmed): signed a tag locally, pushed it, deleted the local copy, and `muse tag list --remote local` correctly retrieved it from the server with signed=true -- not just mocked unit tests on either side. Also confirmed (and left as a separate, pre-existing, out-of-scope finding) that `muse pull` only syncs version tags when it also has new commits to fetch -- an "up_to_date" pull returns before ever reaching the version-tag sync block. `tag list --remote` sidesteps this entirely by always actively fetching, which is exactly the use case this ticket item asked for.

Targeted regression suite -- test_version_tag_signing.py (13), test_cmd_tag_sign.py (7), test_cmd_tag_list_remote.py (7), test_core_version_tags.py, test_cmd_version_tags.py, test_cmd_version_tag_push.py, test_cmd_describe_version_tags.py -- all pass (215 passed), zero regressions.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

sha256:28a95becb74c420d11509bbbda05cdf4afc9751f1dc6aabd77e9d8d9e4ae7306 sha
+82 ~27 symbols
7 changed · 1220 in snapshot files
sha256:c49cc4b34af921665c58ef30d56ace99a55ec538bdd5c03ebe37b797ac21d2f7 snapshot
+82
symbols added
~27
symbols modified
7
files changed
1220
files in snapshot
0
dead code introduced
Semantic Changes 109 symbols
~ tests/test_cmd_tag_list_remote.py .py 22 symbols added
+ TestTagListRemote class class TestTagListRemote L66–170
+ test_does_not_write_local_version_tag_files method method test_does_not_write_local_version_tag_files L86–100
+ test_lists_tags_from_remote_without_local_state method method test_lists_tags_from_remote_without_local_state L67–84
+ test_pre_flag_filters_remote_tags_too method method test_pre_flag_filters_remote_tags_too L102–119
+ test_signed_remote_tag_reports_signer method method test_signed_remote_tag_reports_signer L147–162
+ test_sorted_by_semver_precedence method method test_sorted_by_semver_precedence L121–136
+ test_unconfigured_remote_errors method method test_unconfigured_remote_errors L138–145
+ test_without_remote_flag_still_lists_local method method test_without_remote_flag_still_lists_local L164–170
+ _env function function _env L24–25
+ _init_repo function function _init_repo L28–47
+ _remote_tag_dict function function _remote_tag_dict L50–63
+ cli variable variable cli L20–20
+ CliRunner import import CliRunner L18–18
+ MagicMock import import MagicMock L12–12
+ annotations import import annotations L8–8
+ fake_id import import fake_id L17–17
+ json import import json L10–10
+ muse_dir import import muse_dir L16–16
+ patch import import patch L12–12
+ pathlib import import pathlib L11–11
+ pytest import import pytest L14–14
+ runner variable variable runner L21–21
~ tests/test_cmd_tag_sign.py .py 20 symbols added
+ TestTagAddSign class class TestTagAddSign L69–107
+ test_no_identity_warns_not_crashes method method test_no_identity_warns_not_crashes L90–107
+ test_sign_produces_signed_true method method test_sign_produces_signed_true L70–78
+ test_unsigned_by_default method method test_unsigned_by_default L80–88
+ TestTagVerify class class TestTagVerify L110–172
+ test_verify_nonexistent_tag_errors method method test_verify_nonexistent_tag_errors L161–172
+ test_verify_tampered_tag_fails method method test_verify_tampered_tag_fails L137–159
+ test_verify_unsigned_tag_fails method method test_verify_unsigned_tag_fails L124–135
+ test_verify_valid_signature method method test_verify_valid_signature L111–122
+ _HUB variable variable _HUB L20–20
+ _env function function _env L23–24
+ _run function function _run L27–28
+ _signed_repo function function _signed_repo L31–66
+ CliRunner import import CliRunner L17–17
+ annotations import import annotations L9–9
+ json import import json L11–11
+ long_id import import long_id L16–16
+ pathlib import import pathlib L12–12
+ pytest import import pytest L14–14
+ runner variable variable runner L19–19
~ tests/test_version_tag_signing.py .py 29 symbols added
+ TestSignAndVerifyVersionTag class class TestSignAndVerifyVersionTag L68–122
+ test_sign_produces_prefixed_values method method test_sign_produces_prefixed_values L69–73
+ test_verify_malformed_public_key_returns_false_not_raises method method test_verify_malformed_public_key_returns_false_not_raises L118–122
+ test_verify_tampered_commit_id_returns_false method method test_verify_tampered_commit_id_returns_false L90–102
+ test_verify_unsigned_record_returns_false method method test_verify_unsigned_record_returns_false L86–88
+ test_verify_valid_signature_returns_true method method test_verify_valid_signature_returns_true L75–84
+ test_verify_wrong_public_key_returns_false method method test_verify_wrong_public_key_returns_false L104–116
+ TestVersionTagPayload class class TestVersionTagPayload L50–65
+ test_deterministic method method test_deterministic L51–54
+ test_differs_on_any_field method method test_differs_on_any_field L56–60
+ test_is_hex_sha256 method method test_is_hex_sha256 L62–65
+ TestVersionTagRecordSigningFieldsRoundTrip class class TestVersionTagRecordSigningFieldsRoundTrip L125–154
+ test_defaults_are_empty_string method method test_defaults_are_empty_string L126–129
+ test_from_dict_missing_signing_fields_defaults_empty method method test_from_dict_missing_signing_fields_defaults_empty L143–154
+ test_from_dict_round_trip_preserves_signing_fields method method test_from_dict_round_trip_preserves_signing_fields L137–141
+ test_to_dict_includes_signing_fields method method test_to_dict_includes_signing_fields L131–135
+ _make_record function function _make_record L30–47
+ _utc function function _utc L26–27
+ Ed25519PrivateKey import import Ed25519PrivateKey L13–13
+ VersionTagRecord import import VersionTagRecord L17–17
+ annotations import import annotations L8–8
+ compute_version_tag_id import import compute_version_tag_id L17–17
+ content_hash import import content_hash L16–16
+ datetime import import datetime L10–10
+ parse_semver import import parse_semver L15–15
+ pytest import import pytest L12–12
+ sign_version_tag import import sign_version_tag L17–17
+ verify_version_tag import import verify_version_tag L17–17
+ version_tag_payload import import version_tag_payload L17–17
~ muse/cli/commands/pull.py .py 1 symbol modified
~ run
~ muse/cli/commands/tag.py .py 4 symbols added, 17 symbols modified
+ _TagVerifyJson class class _TagVerifyJson L159–163
+ sign_version_tag import import sign_version_tag L82–82
+ verify_version_tag import import verify_version_tag L82–82
+ run_verify function function run_verify L614–673
~ muse/core/version_tags.py .py 7 symbols added, 5 symbols modified
+ _VTAG_SIGN_PREFIX variable variable _VTAG_SIGN_PREFIX L298–298
+ _VTAG_SIGN_SEP variable variable _VTAG_SIGN_SEP L299–299
+ TYPE_CHECKING import import TYPE_CHECKING L50–50
+ hashlib import import hashlib L44–44
+ sign_version_tag function function sign_version_tag L319–332
+ verify_version_tag function function verify_version_tag L335–355
+ version_tag_payload function function version_tag_payload L302–316
Files Changed
+3 ~4
1220 in snapshot
← Older Oldest on feat/57-version-tag-sign-and-remote-list
All commits
Newer → Latest on feat/57-version-tag-sign-and-remote-list

0 comments

No comments yet. Be the first to start the discussion.

To add a comment, use the Muse CLI: muse hub commit comment sha256:28a95becb74c420d11509bbbda05cdf4afc9751f1dc6aabd77e9d8d9e4ae7306 --body "your comment"