Skip to content

Releases: dfch/biz.dfch.SpecMgr

v0.25.0 - QA Introduction Accepts Any Markdown Content

Choose a tag to compare

@github-actions github-actions released this 09 Sep 05:36

Changed

  • qa: the ### Introduction body now accepts any markdown content (a
    bullet list, code block, block quote, etc.), not just plain paragraphs
    -- the field is retyped from a paragraph list to an opaque markdown
    leaf. The optional leading comment and the section's optionality are
    unchanged (GitHub issue #114).

Fixed

  • models/md: a soft-wrapped (CommonMark lazy-continuation) list item
    now fails with an actionable error -- field path, 1-based line, and an
    explicit "soft-wrapped/lazy-continuation list items are not supported"
    cause/fix hint -- instead of an opaque parsing failure, across every
    structurally-checked list item in all twelve whole-body domains
    (tsk, feat, rsk) (GitHub issue #99).

v0.24.0 - Cap Truncated Validate Error Messages

Choose a tag to compare

@github-actions github-actions released this 08 Sep 11:28

Fixed

  • validate: ValidationErrorEntry.message is now capped at 300
    characters (_MAX_VALIDATE_ERROR_CHARS, plus a trailing
    "... (truncated)" suffix when truncation occurs) instead of reusing
    the caught exception's str() verbatim without limit -- a structurally
    malformed document (e.g. an unexpected/duplicate heading) could
    otherwise produce a message several hundred characters long once
    wrap_tool_errors's domain/tool/channel label was prepended. Also fixed
    models/md/_markdown.py::not_in_mdformat_message()'s global-mismatch
    (line_no == 0) branch, which embedded the full raw text/formatted
    via repr() with no bound -- it now routes both through the existing
    snippet() helper, matching every sibling message-builder in that
    module (GitHub issue #110).

v0.23.0 - Non-Raising Invalid-Status Result For Set Status

Choose a tag to compare

@github-actions github-actions released this 07 Sep 05:07

Fixed

  • set_status: an out-of-vocabulary status value for a given type (all
    13 domains, including adr) no longer raises a pydantic.ValidationError
    -- it returns a new, non-raising InvalidStatusResult
    (valid/type/status/allowed_values/message) instead, so the
    allowed-values detail survives MCP clients that truncate isError: true
    results. Every other set_status failure mode (unknown id,
    path-injection/wrong-shape id, superseded_by misuse on a non-adr
    type) still raises unchanged. New ADR
    b399f1ce-ed42-4929-b01c-7a57d18e8014 extends ADR
    519d1206's non-raising, structured-result workaround (previously scoped
    to validate) to this case (GitHub issue #103).

v0.22.0 - Consolidate Validation Into A Generic Validate Tool

Choose a tag to compare

@github-actions github-actions released this 04 Sep 21:57

Added

  • Generic validate(type, content, full) MCP tool in general/tools/:
    the disk-free, id-free dry-run content validator for the twelve
    whole-body domains (type is one of
    req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr/sysrs;
    adr is not supported -- validate_adr remains its own standalone,
    unchanged tool). Unlike every other generic tool in general/tools/, it
    never raises for a content-validation failure: it always returns a
    structured {valid: bool, errors: list[{message: str}]} result,
    reusing feat-27-validation's already-enriched exception messages
    verbatim as each error's message -- only a full/content-shape
    mismatch (full=True with body-only content, or full=False with a
    complete document) or an unsupported type still raises ValueError,
    since that is a caller-usage error, not a content-validation failure.
    This is the sole validate entry point for these twelve domains: every
    current and future domain implements a validate adapter in the
    generic tool, never a per-domain validate_<d> tool. New ADR
    (078bf395-0a5f-4afd-84f6-b7a2191a00e6) extends ADR
    36905d5b-8057-4294-8665-c7eed5534db0's dispatch-only convention
    (previously covering only mutation-adjacent tools) to this read-only/
    dry-run tool category (GitHub issues #81/#83).
  • Dedicated Pydantic models with drift-guard unittests for
    specmgr://dtais, specmgr://rsk/tara, specmgr://rsk/risk-matrix,
    and specmgr://rasci; all four are now also parsed on every resource
    call to fail fast on structural drift, discarding the parsed result,
    matching specmgr://iso25010's existing pattern. New
    specmgr://ears resource documenting the EARS (Easy Approach to
    Requirements Syntax) five requirement-phrasing templates, likewise
    backed by a model and drift-guard tests -- content aligned with the
    source paper's (Mavin et al., "Easy Approach to Requirements Syntax
    (EARS)", RE'09) exact pattern names, order, and sentence templates,
    and now includes the paper's own worked example for each pattern. New
    ADR (356d8781-e446-4c26-917a-eda85648ce9d) documenting the resulting
    repo-wide convention for reference resources (GitHub issue #92).
  • Generated JSON Schema for the twelve whole-body domains now documents
    the created/updated frontmatter fields' yyyy-MM-dd HH:mm:ss.fff +
    Z/±HH:mm timestamp format as a pattern constraint, without
    changing the existing runtime validation behavior (adr's schema is
    unaffected) (GitHub issue #94).

Changed

  • BREAKING (0.x): list_<d> (all twelve whole-body domains --
    req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr/sysrs;
    list_adr is unaffected, out of scope) no longer silently skips a
    document that fails to parse: it now appears inline in results as its
    own failed entry (id=None, title/status both the fixed marker
    "<failed to parse>", ref/path populated the same way as a
    successful entry, and a new error: str | None field carrying the
    caught exception's message), and total now includes failed entries
    alongside successes -- a deliberate semantics change from the previous
    "parseable documents only" total. PagedResult gains a new
    error_count: int = 0 field, counting failed entries across the whole
    base directory, independent of offset/max_results paging (mirroring
    total's own already-documented across-all-pages semantics). Every
    domain's summary type (DocSummary subclass) also gains a path: str
    field -- an absolute, resolved filesystem path -- for the eleven
    non-feat whole-body domains. Callers relying on a malformed document
    being silently absent from results/uncounted in total must instead
    check each entry's error field (GitHub issue #83).
  • feat/FeatSummary's own, previously feat-only path field is removed
    in favor of the same, now-shared DocSummary.path field every other
    whole-body domain's summary carries (see above), and is retrofitted in
    the same pass to the same absolute, resolved form the other eleven
    domains use -- FeatSummary.path was previously left in its pre-existing
    unresolved str(path) form. This completes list_<d>'s path-field
    parity across all twelve whole-body domains (GitHub issue #81).
  • specmgr://iso25010 now returns raw markdown (text/markdown)
    instead of a structured Iso25010 JSON object, still parsed via
    parse_iso25010() on every read to fail fast on structural drift
    (GitHub issue #92).
  • Local pytest pre-commit hook and CI's "Run unit tests with coverage"
    step now run the (unchanged, still plain unittest.TestCase-based) test
    suite via pytest with pytest-xdist (-n auto) instead of serial
    coverage run -m unittest discover, cutting the local pre-commit gate's
    test step from 9-11 minutes to roughly a minute. pytest-cov produces
    the same .coverage file format, so specmgr coverage-badge is
    unaffected.
  • set_feat_id now returns the domain's FeatFrontmatter object only (no
    body) on a successful rename, matching the frontmatter-only return
    shape create_feat/update/set_status/set_classification already
    use -- it was a bespoke feat-only tool that predated, and was missed
    by, that conversion. Callers needing the full document should call
    get_feat afterward (GitHub issue #80).

Removed

  • BREAKING (0.x): the twelve per-domain validate_<d> MCP tools are
    deleted outright (no deprecated wrappers): validate_req, validate_uc,
    validate_tsk, validate_qa, validate_prb, validate_gol,
    validate_rsk, validate_dec, validate_sop, validate_feat,
    validate_vcr, validate_sysrs -- each raised on a content-validation
    failure instead of returning a structured result. The twelve
    per-domain validate_<d>.py modules, their __init__.py
    registrations, and their dedicated tests are gone with them. Callers
    must switch from tools/call --tool-name validate_<d> to
    tools/call --tool-name validate with the explicit type parameter
    (see "Added" above) -- and, since validate never raises for a
    content-validation failure, callers checking {valid: bool} on the
    returned result instead of catching an exception. validate_adr is
    unaffected and remains unchanged.

v0.21.0 - Fix Placeholder Timestamps in Templates and Examples

Choose a tag to compare

@github-actions github-actions released this 03 Sep 10:29

Changed

  • README now flags the Architecture Decision Record (ADR) artifact type
    as deprecated in favor of Decision (DEC).

Fixed

  • Replaced round, all-zero-time-of-day placeholder timestamps (frontmatter
    created/updated, and feat's body-level #### {timestamp} headings)
    across all 24 affected domain template/example files with realistic,
    non-round values, so they no longer invite copy-paste-without-substitution
    (GitHub issue #67).

v0.20.0 - Confluence Page Title From Markdown H1

Choose a tag to compare

@github-actions github-actions released this 02 Sep 23:48

Added

  • confluence_update now sets the Confluence page's title from the source
    markdown's first H1 heading, falling back to the existing (GET-fetched)
    title unchanged when no H1 is present (GitHub issue #76).

Fixed

  • specmgr://config now reports the sysrs domain (it was missing from
    the resource's domains dict even though sysrs was already fully
    wired into every dispatch tool), matching every other domain's entry
    (GitHub issue #74).
  • Corrected stale/incorrect copyright holders in NOTICE for pydantic,
    python-dotenv, typer, rich, and mcp; added missing attributions
    for mdformat, mdformat-simple-breaks, and httpx (GitHub issue #73).

v0.19.0 - MCP Write Tools Return Frontmatter-Only

Choose a tag to compare

@github-actions github-actions released this 02 Sep 22:59

Changed

  • On a successful write, the generic update, set_status (its twelve
    non-adr adapters), and set_classification tools, and every
    per-domain create_<d> tool (req/uc/tsk/qa/prb/gol/rsk/
    dec/sop/feat/vcr/sysrs), now return the domain's frontmatter
    object only, instead of the full document with its (potentially large,
    ever-growing) body. The adr dispatch branch of set_status and every
    ADR-specific tool (create_adr, update_frontmatter, update_section,
    the option_* tools) are unchanged and still return the full document
    (GitHub issue #69).

v0.18.0 - Add System Requirements Specification (SYSRS) Domain

Choose a tag to compare

@github-actions github-actions released this 02 Sep 20:57

Added

  • New sysrs (System Requirements Specification) domain: an aggregator
    document type that ties together existing gol/prb/qa/uc/req/
    rsk/dec/adr/vcr artifacts into one coherent, navigable
    specification via per-section, type-tagged cross-reference lists (e.g.
    ### Goals accepts only GOL bullets, ## Decisions accepts DEC or
    ADR, and the nine ## Requirements H3s plus six ## Other Characteristics H3s each accept only REQ) rather than duplicating
    their content. Dispatch-only from day one, with no update_sysrs/
    set_status_sysrs tools of its own — whole-body/line-range updates,
    status changes, classification changes, and deletions all go through
    the existing generic update/set_status/set_classification/
    delete tools (type="sysrs"). Ships 7 tools (create_sysrs,
    parse_sysrs, list_sysrs, get_sysrs, get_sysrs_example,
    get_sysrs_template, validate_sysrs), 3 resources
    (specmgr://sysrs/schema, specmgr://sysrs/example,
    specmgr://sysrs/template), and 2 prompts (create_sysrs,
    update_sysrs) (GitHub issue #32).

  • New specmgr://config resource: reports, for all twelve document
    domains, the resolved absolute base directory and whether the domain's
    SPECMGR_*_DIR environment variable is explicitly set, so a client can
    self-diagnose a working-directory-relative base-directory
    misconfiguration. Only the twelve known env var names are ever read
    (never os.environ wholesale), so no unrelated secret is ever
    disclosed. The "Add to OpenCode" README example now shows two
    alternatives for pinning the resolved base directory — uv/uvx's
    --directory flag, or explicit SPECMGR_*_DIR environment variables in
    the MCP client config — and documents the previously-missing
    SPECMGR_FEAT_DIR variable (GitHub issue #51).

  • create_uc/update_uc MCP prompts for the uc (Use Case) domain,
    mirroring the req domain's prompt pattern and including
    set_classification guidance (GitHub issue #57).

Fixed

  • format_text()/format_markdown_document() (models/md/_markdown.py),
    and transitively every domain's parse_<d>/create_<d>/validate_<d>/
    update path plus the mdformat CLI command and MCP tool, now render a
    thematic break (---, ***, ___, or any other CommonMark-valid
    variant) as a literal --- instead of mdformat's hardcoded 70-character
    underscore line ("_" * 70, not otherwise configurable upstream — see
    hukkin/mdformat#69). Fixed by wiring the third-party
    mdformat-simple-breaks plugin (pinned exactly, ==0.1.0) into the
    shared mdformat.text(...) call via its mdformat.parser_extension
    entry point (GitHub issue #47).

v0.17.0 - Classification Frontmatter Field and Confluence Update Tool

Choose a tag to compare

@github-actions github-actions released this 02 Sep 13:32

Added

  • create_feat (feat domain) now accepts an optional, caller-chosen
    id: str | None = None parameter — a full, well-formed feat-NNN-slug
    value, validated via assert_feat_id (general/tools/_path_safety.py)
    before any lock/filesystem access. When id is omitted, the default is
    now feat-0-<slug-from-title> — the previous feat-{max existing NNN + 1}-{slug} auto-increment fallback is gone entirely, since NNN is
    meant to be the GitHub issue number a feature tracks, and feat-0-...
    now signals "no issue yet" rather than a scan-derived guess. Either way
    (caller-supplied or defaulted), create_feat raises FileExistsError
    before any write if the resulting id/folder already exists, and raises
    ValueError before any write if a caller-supplied id doesn't match the
    feat-NNN-slug shape. A new set_feat_id(id, new_id) @mcp.tool()
    (feat domain, feat/tools/set_feat_id.py) complements this by letting an
    existing feature's id be renamed afterwards (e.g. once its GitHub issue
    number becomes known): it validates new_id's shape, refuses via
    FileExistsError if the target folder already exists, renames
    <base>/<id>/ to <base>/<new_id>/, rewrites the frontmatter id and
    bumps updated, leaves the body byte-identical, and raises
    FeatNotFoundError if id does not resolve. It runs under
    feat_create_lock() (outermost) then feat_lock(id) (nested) to avoid
    races with create_feat/update/set_status/delete on the same id.
    feat remains dispatch-only for whole-body updates/status changes — no
    update_feat/set_status_feat tool of its own; set_feat_id is a
    distinct, bespoke tool for id changes specifically (GitHub issue #48).

  • Windowed raw reads on the eleven get_<d> MCP tools
    (req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr):
    each now accepts optional read-style offset/limit coordinates for a
    windowed raw read — valid with raw=True only (coordinates with
    raw=False raise ValueError), offset 1-based with default 1
    (floored, never errors), limit a line count defaulting to through end
    of body (capped at the remaining lines), and offset > N returning the
    empty string; out-of-range values clamp, consistent with the list_<d>
    paging convention. The window is served by a new no-I/O
    window_body(text, offset, limit) helper in general/tools/_splice.py,
    beside body_text/splice_body, so the raw/splice invariant (the line
    numbers a client sees in any get_<d>(raw=True) read, windowed or not,
    index byte-for-byte into the same text the generic update tool splices
    against) is defined once and shared by all eleven tools (GitHub issue
    #28; ADR 4ec08dcb-fcb7-4961-abaf-ff7803e2f21d).

  • confluence_update MCP tool (general/tools/): writes a local Markdown
    file's rendered content into an existing Confluence page's body via the
    REST API, resolving a bare page id, a browsable page URL, or a REST
    content URL to a numeric page id, GET-ing the page's current
    version/title, rendering the Markdown via markdown-it-py to an
    HTML fragment, and PUT-ing the incremented version. Local images
    referenced by the Markdown file are uploaded as Confluence attachments
    on a best-effort basis (POST .../child/attachment, falling back to
    .../child/attachment/{id}/data if the filename already exists --
    duplicate-filename detection is confirmed against a real Confluence
    server's actual 400 response, "Cannot add a new attachment with same
    file name as an existing attachment: <filename>. Log referral number
    is <uuid>") and their <img> tags are rewritten into Confluence's
    <ac:image>/<ri:attachment> storage-format macro. Also sanitizes any
    raw -- sequence inside rendered <!-- --> HTML comments (valid
    CommonMark but rejected outright by Confluence's strict XHTML
    storage-format parser, confirmed against a real instance: "Error parsing xhtml: String '--' not allowed in comment") and converts a
    leading YAML frontmatter block into a fenced code block before
    rendering, instead of letting CommonMark's thematic-break/Setext-heading
    rules mangle it into a stray <h2> heading (also confirmed against a
    real instance). Closes GitHub issue #50, per ADR
    a156fdf9-052c-4f43-93a2-eeec04a91eac.

  • confluence_update/confluence_fetch MCP prompts (general/prompts/):
    thin, single-tool-call prompts sharing their respective tools' exact
    names (a separate MCP registry from tools). Each returns instructional
    text telling the LLM to call the matching confluence_update/
    confluence_fetch tool with the given parameters -- neither prompt ever
    calls its tool itself. confluence_update also tells the LLM to report
    back the tool's returned version/failed_images; confluence_fetch
    documents that destination_path is only required for binary/non-text
    content. Part of feat-50-confluence Phase 8, REQ-012/REQ-013.

  • Optional, free-text classification frontmatter field on the shared
    MarkdownFrontmatter base, inherited by all eleven whole-body domains
    (req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr; ADR
    excluded, since it has its own separate frontmatter model). A new
    generic set_classification(id, type, classification) MCP tool
    (general/tools/) mirrors set_status's dispatch pattern to change it
    after creation, bumping updated and leaving the body and every other
    frontmatter field untouched; a blank/whitespace-only value clears
    classification back to None/absent, same as every other optional
    frontmatter field's blank-to-None normalization. Existing documents
    without a classification key keep parsing unchanged. The ten
    whole-body domains' packaged create/update prompt instruction files
    (uc, which has no prompts sub-package yet, is untouched) now mention
    set_classification alongside the existing set_status mentions
    (GitHub issue #56).

Changed

  • BREAKING (0.x): the generic update MCP tool's 1-based inclusive
    begin/end body-line range (with the N+1 end-of-body sentinel) is
    replaced by read-style offset/limit coordinates in a hard rename (no
    compatibility alias): offset is the 1-based first line to replace
    (allowed 1..N+1, where N+1 is the virtual end-of-body append
    position), limit is the number of lines (offset..offset+limit-1);
    omitted limit replaces through the last body line, limit=0 is a pure
    insert. Out-of-range coordinates raise ValueError (strict, never
    clamped, nothing written) and limit without offset raises
    ValueError before any file access; splice-then-validate-whole, verbatim
    persistence, and frontmatter carry-over are unchanged. Every LLM-facing
    surface (the packaged prompt instruction files, tool descriptions,
    docstrings, AGENTS.md) moved to the new vocabulary in this same
    release. The revised contract is recorded in ADR
    4ec08dcb-fcb7-4961-abaf-ff7803e2f21d (referencing, not superseding, ADR
    36905d5b-8057-4294-8665-c7eed5534db0) (GitHub issue #28).

  • Eliminated all 42 pylint W0622 (redefined-builtin) findings via 39
    explicit, per-file # pylint: disable=redefined-builtin comments (with
    a one-line rationale) on the files whose public API intentionally uses
    id/type as parameter names — the twelve get_<d> tools, the
    per-domain update/implement prompts, the ADR tools/resources/prompts,
    the generic update/set_status/delete public functions, and
    models/md/markdown.py/alias.py. Not breaking — no behavior change,
    purely an internal lint-suppression change. No global pyproject.toml
    pylint configuration change: a future file that shadows a builtin
    without adding its own disable comment still warns (GitHub issue #41,
    Phase 5 of feat-38-39-41-43-44).

  • The twelve get_<d> tools (including get_adr), the generic update
    tool, and the generic set_status tool now validate id for
    path-injection/wrong-format before any filesystem access, and confine
    the resolved path to the domain's own base directory after resolution —
    the same general.tools._path_safety guards the generic delete tool
    already had (feat-36-delete). _path_safety.validate_id now also
    accepts "adr" as a UUID-shaped domain. This is purely additive
    validation: a previously well-formed id for its domain is unaffected; a
    path-injection attempt or a malformed id — which would already have
    failed downstream (e.g. via a FileNotFoundError/XNotFoundError) —
    now fails earlier and more explicitly with a ValueError. delete
    itself is unchanged (GitHub issue #43, Phase 4 of
    feat-38-39-41-43-44).

  • BREAKING: renamed the webfetch MCP tool to confluence_fetch (and
    its environment variables SPECMGR_WEBFETCH_BASE_URL/
    SPECMGR_WEBFETCH_BEARER to SPECMGR_CONFLUENCE_BASE_URL/
    SPECMGR_CONFLUENCE_BEARER); part of feat-50-confluence. Beyond the
    rename, confluence_fetch now auto-converts browsable Confluence page
    URLs (Cloud-style /pages/<id>/<title> and Server-style
    ?pageId=<id>) into the equivalent
    {base}/rest/api/content/{id}?expand=body.storage REST API URL before
    fetching, rejects the /x/<tinyid> tiny-link URL shape with a clear
    error (unresolvable to a page id without an authenticated browser
    session), detects when a request is redirected off the configured base
    URL's host (e.g. to an SSO login page) and raises instead of returning
    that content, and supports binary/image download via a
    destination_path parameter (content-type based). GitHub issue #50,
    ADR a156fdf9-052c-4f43-93a2-eeec04a91eac.

  • BREAKING: frontmatter created/updated now strictly require the
    date+time variant yyyy-MM-dd HH:mm:ss.fff followed by Z (UTC) or a
    signed ±HH:mm offset — date-only, T-separated, six-digit-microsecond,
    and timezone-less values are all rejected at parse time
    (pydantic.ValidationError), eagerly, o...

Read more

v0.16.0 - Generic delete tool

Choose a tag to compare

@github-actions github-actions released this 01 Sep 07:54

Added

  • Generic delete(id, type) MCP tool in general/tools/: the
    type-dispatched hard-delete for the eleven whole-body domains (type is
    one of req/uc/tsk/qa/prb/gol/rsk/dec/sop/feat/vcr;
    adr is not supported). Resolves the document by id under the
    domain's own per-id lock and removes it — the single *.md file for the
    ten flat domains, or the entire <base>/<id>/ folder for feat
    returning the deleted path as a string. An invalid id (path-injection
    attempt or wrong format) is a ValueError raised before any file access;
    a missing document is the domain's own XNotFoundError; an I/O failure
    during the delete is a DeleteError (an OSError subclass). This is
    the sole delete entry point: every current and future domain implements
    a delete adapter in the generic tool, never a per-domain delete_<d>
    tool.
  • A reusable, doc-type-agnostic path-safety module
    general/tools/_path_safety.py: assert_no_traversal, assert_uuid,
    assert_feat_id, validate_id, and assert_within — pure, no-I/O
    guards preventing path-injection through type/id inputs and confining
    resolved paths to their base directory. Wired into the new delete tool
    now; designed so the get_<d>, update, and set_status tools can
    adopt it later with zero rework (they are not modified in this change).

Changed

  • The release SOP (now active — its status was draft until the first
    release executed under it succeeded end to end, v0.15.0) was clarified:
    tool prerequisites and the stage-to-step execution map up front in
    Scope, the fast-forward-only merge mechanism the script actually uses
    (pre- and post-merge SHA assertions around the plain merge method,
    replacing the description of a nonexistent gh pr merge --ff-only),
    the publication workflow's name ("Publish to PyPI") vs. file
    (.github/workflows/publish.yml) distinction, and a dedicated
    precaution about the old gh 2.4.0 the script targets.

Removed

  • BREAKING (0.x): the eleven per-domain delete_<d> stub MCP tools are
    deleted outright (no deprecated wrappers): delete_req, delete_uc,
    delete_tsk, delete_qa, delete_prb, delete_gol, delete_rsk,
    delete_dec, delete_sop, delete_feat, delete_vcr — each was a
    registered stub that always raised NotImplementedError. The eleven
    per-domain delete_<d>.py modules, their __init__.py registrations,
    and their stub tests are gone with them. Callers must switch from
    tools/call --tool-name delete_<d> to tools/call --tool-name delete
    with the explicit type parameter (see "Added" above).

Fixed

  • scripts/release.sh: the pr-merge, publish-wait, status, and
    release-notes stages no longer rely on gh CLI features that do not
    exist in this environment's gh 2.4.0 — a nonexistent --ff-only
    merge flag, gh run list --commit, gh run view --json jobs, and
    gh release view --json/gh release edit. Fast-forward-only merging
    is now enforced by pre-merge and post-merge SHA assertions around the
    plain merge method; the publication run is located by workflow name
    ("Publish to PyPI") plus the tag's commit SHA (filtered with jq);
    the GitHub Release is read and its notes set through gh api.