Skip to content

versions

mkdocs_terok.versions

Stateless versioned-docs assembly for terok-* GitHub Pages sites.

Every PyPI release ships its built docs as an immutable release asset (docs-site.tar.gz); the served site is reassembled from scratch on every deploy: the newest final release of each of the last few minors plus a fresh /dev/ build, with versions.json — the contract Material's version chooser reads — derived from the release list. Nothing is stored between deploys, so there is no gh-pages branch to protect, no history to squash, and a deploy is a pure function of the release set and master.

Retention is an assembly parameter: only the newest keep minors are served (the chooser and the site plateau instead of growing with the release cadence); older versions stay downloadable from their release assets forever. Root assets — files hotlinked at the site root by READMEs on GitHub and in immutable PyPI descriptions — are copied out of the dev build so their well-known URLs survive versioning.

The module is IO-light on purpose: the publish-versioned-docs.yml reusable workflow fetches the release list and downloads the snapshot tarballs, while the selection and tree-layout logic lives here, where it is testable.

assemble(*, dev_site, snapshots, entries, out, root_assets=())

Lay out the complete site tree for a Pages deploy.

Consumes its inputs: dev_site and the snapshot directories are moved into the tree, not copied — they are per-run scratch extracts, and a copy would double the whole served site on the deploy path.

root_assets are the site's well-known root URLs: files (READMEs hotlink logos from GitHub and immutable PyPI descriptions) that must stay reachable at <site>/<asset> even though the versioned tree buries every build under a version directory. Each is copied from the dev build to the same path at the tree root — before the assembler writes its own root files, so versions.json and the redirect index.html can never be shadowed.

Parameters:

Name Type Description Default
dev_site Path

Freshly built ProperDocs output for master.

required
snapshots Path

Directory holding one unpacked snapshot per served minor (snapshots/<minor>/), as planned by plan.

required
entries list[dict]

The plan — newest minor first.

required
out Path

Tree to create; replaced wholesale if it exists.

required
root_assets Sequence[str]

Site-relative files to copy from the dev build to the tree root.

()

Raises:

Type Description
ValueError

an entry's minor is not a plain X.Y name, a root asset is not a plain relative path or is missing from the dev build, or out points at an existing non-empty directory that is not a previously assembled tree (mispointed --out).

Source code in src/mkdocs_terok/versions.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def assemble(
    *,
    dev_site: Path,
    snapshots: Path,
    entries: list[dict],
    out: Path,
    root_assets: Sequence[str] = (),
) -> None:
    """Lay out the complete site tree for a Pages deploy.

    Consumes its inputs: *dev_site* and the snapshot directories are
    moved into the tree, not copied — they are per-run scratch extracts,
    and a copy would double the whole served site on the deploy path.

    *root_assets* are the site's well-known root URLs: files (READMEs
    hotlink logos from GitHub and immutable PyPI descriptions) that must
    stay reachable at ``<site>/<asset>`` even though the versioned tree
    buries every build under a version directory.  Each is copied from
    the dev build to the same path at the tree root — before the
    assembler writes its own root files, so ``versions.json`` and the
    redirect ``index.html`` can never be shadowed.

    Args:
        dev_site: Freshly built ProperDocs output for master.
        snapshots: Directory holding one unpacked snapshot per served
            minor (``snapshots/<minor>/``), as planned by
            [`plan`][mkdocs_terok.versions.plan].
        entries: The plan — newest minor first.
        out: Tree to create; replaced wholesale if it exists.
        root_assets: Site-relative files to copy from the dev build to
            the tree root.

    Raises:
        ValueError: an entry's ``minor`` is not a plain ``X.Y`` name, a
            root asset is not a plain relative path or is missing from
            the dev build, or *out* points at an existing non-empty
            directory that is not a previously assembled tree
            (mispointed ``--out``).
    """
    for entry in entries:
        if not _MINOR.fullmatch(entry["minor"]):
            raise ValueError(f"unsafe minor in plan: {entry['minor']!r}")
    for asset in root_assets:
        parts = PurePosixPath(asset).parts
        if not parts or parts[0] == "/" or ".." in parts:
            raise ValueError(f"unsafe root asset: {asset!r}")
    _ensure_replaceable(out)
    if out.exists():
        shutil.rmtree(out)
    out.mkdir(parents=True)
    shutil.move(dev_site, out / "dev")
    for asset in root_assets:
        source = out / "dev" / asset
        if not source.is_file():
            raise ValueError(f"root asset missing from the dev build: {asset}")
        (out / asset).parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, out / asset)
    for entry in entries:
        shutil.move(snapshots / entry["minor"], out / entry["minor"])
    chooser = [{"version": "dev", "title": "dev", "aliases": []}] + [
        {
            "version": entry["minor"],
            "title": entry["tag"].lstrip("v"),
            "aliases": ["latest"] if entry is entries[0] else [],
        }
        for entry in entries
    ]
    (out / "versions.json").write_text(json.dumps(chooser, indent=2) + "\n")
    target = entries[0]["minor"] if entries else "dev"
    (out / "index.html").write_text(_ROOT_REDIRECT.format(target=target))
    (out / ".nojekyll").touch()

plan(releases, *, keep)

Select which release snapshots the served site carries.

From the GitHub release list, final vX.Y.Z releases carrying the docs asset are grouped by minor; the highest patch wins its minor, and the newest keep minors survive.

Parameters:

Name Type Description Default
releases list[dict]

Release objects as returned by the GitHub API (tag_name, draft, assets[].name are consulted).

required
keep int

How many minors to serve.

required

Returns:

Type Description
list[dict]

Entries {"minor", "tag"}, newest minor first.

Source code in src/mkdocs_terok/versions.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def plan(releases: list[dict], *, keep: int) -> list[dict]:
    """Select which release snapshots the served site carries.

    From the GitHub release list, final ``vX.Y.Z`` releases carrying the
    docs asset are grouped by minor; the highest patch wins its minor,
    and the newest *keep* minors survive.

    Args:
        releases: Release objects as returned by the GitHub API
            (``tag_name``, ``draft``, ``assets[].name`` are consulted).
        keep: How many minors to serve.

    Returns:
        Entries ``{"minor", "tag"}``, newest minor first.
    """
    best: dict[tuple[int, int], tuple[int, str]] = {}
    for release in releases:
        match = _FINAL_TAG.fullmatch(release.get("tag_name", ""))
        if not match or release.get("draft"):
            continue
        if not any(asset.get("name") == DOCS_ASSET for asset in release.get("assets", ())):
            continue
        major, minor, patch = (int(part) for part in match.groups())
        if patch >= best.get((major, minor), (-1, ""))[0]:
            best[(major, minor)] = (patch, release["tag_name"])
    newest = sorted(best, reverse=True)[:keep]
    return [
        {"minor": f"{major}.{minor}", "tag": best[(major, minor)][1]} for major, minor in newest
    ]