imprenta 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imprenta-0.1.0/.github/scripts/roll_changelog.py +99 -0
- imprenta-0.1.0/.github/workflows/ci.yml +65 -0
- imprenta-0.1.0/.github/workflows/publish.yml +80 -0
- imprenta-0.1.0/.github/workflows/release.yml +256 -0
- imprenta-0.1.0/.gitignore +10 -0
- imprenta-0.1.0/CHANGELOG.md +44 -0
- imprenta-0.1.0/CITATION.cff +12 -0
- imprenta-0.1.0/CLAUDE.md +62 -0
- imprenta-0.1.0/LICENSE +21 -0
- imprenta-0.1.0/Makefile +43 -0
- imprenta-0.1.0/PKG-INFO +195 -0
- imprenta-0.1.0/README.md +158 -0
- imprenta-0.1.0/pyproject.toml +76 -0
- imprenta-0.1.0/src/imprenta/__init__.py +30 -0
- imprenta-0.1.0/src/imprenta/__main__.py +3 -0
- imprenta-0.1.0/src/imprenta/build.py +161 -0
- imprenta-0.1.0/src/imprenta/catalog.py +245 -0
- imprenta-0.1.0/src/imprenta/check.py +362 -0
- imprenta-0.1.0/src/imprenta/cli.py +153 -0
- imprenta-0.1.0/src/imprenta/content.py +74 -0
- imprenta-0.1.0/src/imprenta/data/toc.lua +94 -0
- imprenta-0.1.0/src/imprenta/edit.py +150 -0
- imprenta-0.1.0/src/imprenta/parts.py +196 -0
- imprenta-0.1.0/src/imprenta/project.py +154 -0
- imprenta-0.1.0/src/imprenta/render.py +177 -0
- imprenta-0.1.0/src/imprenta/targets.py +190 -0
- imprenta-0.1.0/src/imprenta/tui.py +567 -0
- imprenta-0.1.0/tests/conftest.py +141 -0
- imprenta-0.1.0/tests/test_catalog.py +91 -0
- imprenta-0.1.0/tests/test_check.py +176 -0
- imprenta-0.1.0/tests/test_edit.py +88 -0
- imprenta-0.1.0/tests/test_parts.py +96 -0
- imprenta-0.1.0/tests/test_targets.py +110 -0
- imprenta-0.1.0/tests/test_tui.py +44 -0
- imprenta-0.1.0/uv.lock +699 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Move CHANGELOG.md's [Unreleased] entries under a new version heading.
|
|
2
|
+
|
|
3
|
+
Usage: roll_changelog.py CHANGELOG TAG PREVIOUS_TAG DATE NOTES_OUT [TAG_ALREADY_AT_HEAD]
|
|
4
|
+
roll_changelog.py --notes CHANGELOG TAG NOTES_OUT
|
|
5
|
+
|
|
6
|
+
Called by release.yml. Writes the release notes to NOTES_OUT and edits the
|
|
7
|
+
changelog in place; touches neither when there is nothing to release:
|
|
8
|
+
|
|
9
|
+
- [Unreleased] has entries: they become ``## [X.Y.Z] - DATE`` and an empty
|
|
10
|
+
[Unreleased] is left above them.
|
|
11
|
+
- [Unreleased] is empty but the commit already shipped as another tag (a manual
|
|
12
|
+
minor/major bump after the automatic patch): a short ``## [X.Y.Z]`` entry
|
|
13
|
+
points at that release, whose entries become the notes.
|
|
14
|
+
|
|
15
|
+
Keep a Changelog link references at the bottom are updated when present.
|
|
16
|
+
|
|
17
|
+
With --notes, CHANGELOG has already been rolled for TAG (a re-run resuming a
|
|
18
|
+
release whose changelog commit reached main): only the notes are written, the
|
|
19
|
+
same ones the roll produced. Exits 1 if CHANGELOG has no section for TAG.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
HEADING = re.compile(r"^## \[(?P<name>[^\]]+)\]", re.MULTILINE)
|
|
29
|
+
LINKS = re.compile(r"^\[[^\]]+\]: ", re.MULTILINE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def section(text: str, name: str) -> tuple[int, int, int] | None:
|
|
33
|
+
"""(heading start, body start, body end) of the ``## [name]`` section."""
|
|
34
|
+
for m in HEADING.finditer(text):
|
|
35
|
+
if m.group("name") == name:
|
|
36
|
+
body = text.index("\n", m.start()) + 1
|
|
37
|
+
nxt = HEADING.search(text, body)
|
|
38
|
+
links = LINKS.search(text, body)
|
|
39
|
+
ends = [x.start() for x in (nxt, links) if x] + [len(text)]
|
|
40
|
+
return m.start(), body, min(ends)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def update_links(text: str, version: str, base: str) -> str:
|
|
45
|
+
"""Point [Unreleased] at the new tag and add a link for the new version."""
|
|
46
|
+
m = re.search(r"^\[Unreleased\]: (?P<repo>\S+)/compare/\S+\.\.\.HEAD$", text, re.M)
|
|
47
|
+
if not m:
|
|
48
|
+
return text
|
|
49
|
+
repo, tag = m.group("repo"), f"v{version}"
|
|
50
|
+
link = f"{repo}/compare/{base}...{tag}" if base else f"{repo}/releases/tag/{tag}"
|
|
51
|
+
new = f"[Unreleased]: {repo}/compare/{tag}...HEAD\n[{version}]: {link}"
|
|
52
|
+
return text[: m.start()] + new + text[m.end() :]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SAME_CODE = re.compile(r"^Same code as (?P<tag>v\S+), released as \S+\.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rolled_notes(path: str, tag: str, notes_out: str) -> None:
|
|
59
|
+
text = Path(path).read_text()
|
|
60
|
+
rolled = section(text, tag.removeprefix("v"))
|
|
61
|
+
if rolled is None:
|
|
62
|
+
sys.exit(f"{path} has no section for {tag}")
|
|
63
|
+
notes = text[rolled[1] : rolled[2]].strip()
|
|
64
|
+
if (m := SAME_CODE.match(notes)) and (shipped := section(text, m["tag"].removeprefix("v"))):
|
|
65
|
+
notes = f"{notes}\n\n{text[shipped[1] : shipped[2]].strip()}"
|
|
66
|
+
Path(notes_out).write_text(notes + "\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(path: str, tag: str, previous: str, date: str, notes_out: str, at_head: str = "") -> None:
|
|
70
|
+
file = Path(path)
|
|
71
|
+
text = file.read_text()
|
|
72
|
+
version = tag.removeprefix("v")
|
|
73
|
+
unreleased = section(text, "Unreleased")
|
|
74
|
+
if unreleased is None:
|
|
75
|
+
return
|
|
76
|
+
_, body_start, body_end = unreleased
|
|
77
|
+
body = text[body_start:body_end].strip()
|
|
78
|
+
base = previous
|
|
79
|
+
if body:
|
|
80
|
+
notes = body
|
|
81
|
+
rolled = f"\n## [{version}] - {date}\n\n{body}\n\n"
|
|
82
|
+
elif at_head and (shipped := section(text, at_head.removeprefix("v"))):
|
|
83
|
+
shipped_body = text[shipped[1] : shipped[2]].strip()
|
|
84
|
+
base = at_head
|
|
85
|
+
notes = f"Same code as {at_head}, released as {tag}.\n\n{shipped_body}".strip()
|
|
86
|
+
rolled = f"\n## [{version}] - {date}\n\nSame code as {at_head}, released as {tag}.\n\n"
|
|
87
|
+
else:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
text = text[:body_start] + rolled + text[body_end:].lstrip("\n")
|
|
91
|
+
file.write_text(update_links(text, version, base))
|
|
92
|
+
Path(notes_out).write_text(notes + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
if sys.argv[1:2] == ["--notes"]:
|
|
97
|
+
rolled_notes(*sys.argv[2:])
|
|
98
|
+
else:
|
|
99
|
+
main(*sys.argv[1:])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
# PR runs supersede each other. Every other run gets its own group, so each
|
|
15
|
+
# push to main finishes CI and can be released.
|
|
16
|
+
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
|
17
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
lint:
|
|
21
|
+
name: lint
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Set up uv
|
|
27
|
+
uses: astral-sh/setup-uv@v5
|
|
28
|
+
|
|
29
|
+
# Ruff only, no project install, at the exact version in uv.lock so CI
|
|
30
|
+
# and local `uv sync` lint with the same release.
|
|
31
|
+
- name: Resolve locked ruff version
|
|
32
|
+
run: |
|
|
33
|
+
version=$(awk '/^name = "ruff"$/{f=1} f && /^version = /{gsub(/"/,"",$3); print $3; exit}' uv.lock)
|
|
34
|
+
test -n "$version" || { echo "ruff not found in uv.lock" >&2; exit 1; }
|
|
35
|
+
echo "RUFF_VERSION=$version" >> "$GITHUB_ENV"
|
|
36
|
+
|
|
37
|
+
- name: Lint
|
|
38
|
+
run: uvx "ruff@$RUFF_VERSION" check src tests
|
|
39
|
+
|
|
40
|
+
- name: Format check
|
|
41
|
+
run: uvx "ruff@$RUFF_VERSION" format --check src tests
|
|
42
|
+
|
|
43
|
+
test:
|
|
44
|
+
name: test (py${{ matrix.python-version }})
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
strategy:
|
|
47
|
+
fail-fast: false
|
|
48
|
+
matrix:
|
|
49
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
with:
|
|
53
|
+
fetch-depth: 0 # full history + tags for hatch-vcs versioning
|
|
54
|
+
|
|
55
|
+
- name: Set up uv
|
|
56
|
+
uses: astral-sh/setup-uv@v5
|
|
57
|
+
with:
|
|
58
|
+
python-version: ${{ matrix.python-version }}
|
|
59
|
+
enable-cache: true
|
|
60
|
+
|
|
61
|
+
- name: Install
|
|
62
|
+
run: uv sync --extra dev
|
|
63
|
+
|
|
64
|
+
- name: Test
|
|
65
|
+
run: uv run pytest
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI when a GitHub Release is published, or when release.yml
|
|
4
|
+
# starts it on a new tag. It only runs on a vX.Y.Z tag.
|
|
5
|
+
#
|
|
6
|
+
# Uses PyPI Trusted Publishing (OIDC) — no API token/secret required. Before the
|
|
7
|
+
# first release, configure a Trusted Publisher on PyPI for this project:
|
|
8
|
+
# PyPI → project "imprenta" → Settings → Publishing → add a GitHub publisher with
|
|
9
|
+
# owner=jmcmeen, repo=imprenta, workflow=publish.yml, environment=pypi.
|
|
10
|
+
# Also create a GitHub environment named "pypi" (Settings → Environments).
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
name: Build distributions
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
with:
|
|
27
|
+
fetch-depth: 0 # full history + tags for hatch-vcs versioning
|
|
28
|
+
|
|
29
|
+
- name: Set up uv
|
|
30
|
+
uses: astral-sh/setup-uv@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.12"
|
|
33
|
+
|
|
34
|
+
- name: Require a release tag on main
|
|
35
|
+
run: |
|
|
36
|
+
# Run by hand on a branch, this would build and upload a dev version;
|
|
37
|
+
# on a tag off main, code that never went through main and its CI.
|
|
38
|
+
if [ "$GITHUB_REF_TYPE" != tag ] || ! [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
39
|
+
echo "::error::Publish only runs on a vX.Y.Z tag, not $GITHUB_REF_TYPE '$GITHUB_REF_NAME'."
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
git fetch -q origin +refs/heads/main:refs/remotes/origin/main
|
|
43
|
+
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
|
44
|
+
echo "::error::$GITHUB_REF_NAME isn't on main; only release tags from main are published."
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
- name: Build sdist + wheel
|
|
49
|
+
run: |
|
|
50
|
+
# Version from the tag this run is on, not `git describe`: when two
|
|
51
|
+
# tags share a commit, describe can pick the older one.
|
|
52
|
+
export SETUPTOOLS_SCM_PRETEND_VERSION="${GITHUB_REF_NAME#v}"
|
|
53
|
+
uv build
|
|
54
|
+
|
|
55
|
+
- name: Upload artifacts
|
|
56
|
+
uses: actions/upload-artifact@v4
|
|
57
|
+
with:
|
|
58
|
+
name: dist
|
|
59
|
+
path: dist/
|
|
60
|
+
|
|
61
|
+
publish:
|
|
62
|
+
name: Publish to PyPI
|
|
63
|
+
needs: build
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
environment:
|
|
66
|
+
name: pypi
|
|
67
|
+
url: https://pypi.org/p/imprenta
|
|
68
|
+
permissions:
|
|
69
|
+
id-token: write # required for trusted publishing
|
|
70
|
+
steps:
|
|
71
|
+
- name: Download built distributions
|
|
72
|
+
uses: actions/download-artifact@v4
|
|
73
|
+
with:
|
|
74
|
+
name: dist
|
|
75
|
+
path: dist/
|
|
76
|
+
|
|
77
|
+
- name: Publish
|
|
78
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
79
|
+
with:
|
|
80
|
+
skip-existing: true # a retried release re-dispatches this
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Cuts a release when a merge to main passes CI: the latest vX.Y.Z tag gets its
|
|
4
|
+
# patch number bumped (v0.1.0 if there are no tags yet). Merges that land while
|
|
5
|
+
# a release is running can share the next release (see below), so a release is
|
|
6
|
+
# per merge in the usual case, not guaranteed for each one.
|
|
7
|
+
# Python packages take their version from that tag via hatch-vcs; a repo whose
|
|
8
|
+
# version lives in files (e.g. Cargo.toml) provides .github/scripts/
|
|
9
|
+
# set_version.sh, which is run with the new version (X.Y.Z). CHANGELOG.md's
|
|
10
|
+
# [Unreleased] entries move under the new version in a github-actions[bot]
|
|
11
|
+
# commit on main, along with any set_version.sh edits; that commit is what gets
|
|
12
|
+
# tagged, and the entries become the release notes. If main has moved on by
|
|
13
|
+
# then, nothing is pushed and this merge isn't released on its own: the release
|
|
14
|
+
# for the newer merge includes it (a manual minor/major run says to run again).
|
|
15
|
+
#
|
|
16
|
+
# For a minor or major release, run this workflow by hand on main ("Run
|
|
17
|
+
# workflow", or `gh workflow run release.yml -f bump=minor`). It releases the
|
|
18
|
+
# head of main with that part bumped; the next merge continues from it. If the
|
|
19
|
+
# merge was already released as a patch, the commit carries both tags. It
|
|
20
|
+
# refuses to run until CI has passed on that commit. If a release is already
|
|
21
|
+
# running and a merge's release queues behind your run, GitHub cancels your
|
|
22
|
+
# pending run in its favor -- Actions shows it as cancelled; run it again.
|
|
23
|
+
#
|
|
24
|
+
# Releasing runs are serialized, and GitHub keeps only the newest pending one,
|
|
25
|
+
# so merges that land close together can share a release. The lock is on the
|
|
26
|
+
# job, so skipped runs (failed CI, dispatch off main) never take that slot.
|
|
27
|
+
# A commit that is already inside the latest release is skipped, so an old run
|
|
28
|
+
# finishing late never gets a newer version than the code after it. Re-running
|
|
29
|
+
# a run whose release exists only re-dispatches publish.yml (which skips files
|
|
30
|
+
# already on PyPI).
|
|
31
|
+
|
|
32
|
+
on:
|
|
33
|
+
workflow_run:
|
|
34
|
+
workflows: [CI]
|
|
35
|
+
types: [completed]
|
|
36
|
+
branches: [main]
|
|
37
|
+
workflow_dispatch:
|
|
38
|
+
inputs:
|
|
39
|
+
bump:
|
|
40
|
+
description: Version part to bump
|
|
41
|
+
type: choice
|
|
42
|
+
options: [minor, major, patch]
|
|
43
|
+
default: minor
|
|
44
|
+
|
|
45
|
+
permissions:
|
|
46
|
+
contents: write # changelog commit, tag + release
|
|
47
|
+
actions: write # dispatch publish.yml
|
|
48
|
+
|
|
49
|
+
jobs:
|
|
50
|
+
release:
|
|
51
|
+
name: Tag and release
|
|
52
|
+
if: >-
|
|
53
|
+
(github.event_name == 'workflow_run'
|
|
54
|
+
&& github.event.workflow_run.conclusion == 'success'
|
|
55
|
+
&& github.event.workflow_run.event == 'push')
|
|
56
|
+
|| (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
concurrency:
|
|
59
|
+
group: release
|
|
60
|
+
cancel-in-progress: false
|
|
61
|
+
env:
|
|
62
|
+
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
63
|
+
BUMP: ${{ inputs.bump || 'patch' }}
|
|
64
|
+
GH_TOKEN: ${{ github.token }}
|
|
65
|
+
BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
|
66
|
+
steps:
|
|
67
|
+
- uses: actions/checkout@v4
|
|
68
|
+
with:
|
|
69
|
+
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
70
|
+
fetch-depth: 0 # all tags
|
|
71
|
+
|
|
72
|
+
- name: Require passing CI
|
|
73
|
+
if: github.event_name == 'workflow_dispatch'
|
|
74
|
+
run: |
|
|
75
|
+
# A release commit never runs CI (bot pushes don't trigger it), so
|
|
76
|
+
# check the commit it was made on.
|
|
77
|
+
tested=$SHA
|
|
78
|
+
if git rev-parse -q --verify HEAD^ > /dev/null &&
|
|
79
|
+
[ "$(git log -1 --format=%ae HEAD)" = "$BOT_EMAIL" ] &&
|
|
80
|
+
git log -1 --format=%s HEAD | grep -q '^Update changelog for '; then
|
|
81
|
+
tested=$(git rev-parse HEAD^)
|
|
82
|
+
fi
|
|
83
|
+
passed=$(gh run list --workflow ci.yml --commit "$tested" --event push \
|
|
84
|
+
--status success --json databaseId --jq length)
|
|
85
|
+
if [ "$passed" -eq 0 ]; then
|
|
86
|
+
echo "::error::CI hasn't passed on $tested yet; wait for it, then run this again."
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
- name: Compute version
|
|
91
|
+
id: version
|
|
92
|
+
run: |
|
|
93
|
+
semver='^v[0-9]+\.[0-9]+\.[0-9]+$'
|
|
94
|
+
bump() {
|
|
95
|
+
IFS=. read -r major minor patch <<< "${1#v}"
|
|
96
|
+
case "$BUMP" in
|
|
97
|
+
major) echo "v$((major + 1)).0.0" ;;
|
|
98
|
+
minor) echo "v$major.$((minor + 1)).0" ;;
|
|
99
|
+
*) echo "v$major.$minor.$((patch + 1))" ;;
|
|
100
|
+
esac
|
|
101
|
+
}
|
|
102
|
+
first() { if [ "$BUMP" = major ]; then echo v1.0.0; else echo v0.1.0; fi; }
|
|
103
|
+
# This commit's releases, oldest first: tags on it, or on a release
|
|
104
|
+
# commit the bot made on top of it.
|
|
105
|
+
ours=$(git tag --points-at HEAD | grep -E "$semver" || true)
|
|
106
|
+
for t in $(git tag --contains HEAD | grep -E "$semver" || true); do
|
|
107
|
+
if [ "$(git rev-parse "$t^{commit}^")" = "$(git rev-parse HEAD)" ] &&
|
|
108
|
+
[ "$(git log -1 --format=%ae "$t")" = "$BOT_EMAIL" ] &&
|
|
109
|
+
git log -1 --format=%s "$t" | grep -q '^Update changelog for '; then
|
|
110
|
+
ours="$ours $t"
|
|
111
|
+
fi
|
|
112
|
+
done
|
|
113
|
+
ours=$(printf '%s\n' $ours | grep . | sort -uV || true)
|
|
114
|
+
newest=$(printf '%s\n' $ours | tail -1)
|
|
115
|
+
if [ "$BUMP" = patch ]; then
|
|
116
|
+
# A patch run on a released commit retries its newest release.
|
|
117
|
+
retry=$newest
|
|
118
|
+
else
|
|
119
|
+
# A minor or major run retries only its own bump: a tag of this
|
|
120
|
+
# commit that is exactly that bump of the release before it, or the
|
|
121
|
+
# first release when there is none before it. (Asking for the same
|
|
122
|
+
# bump again on the same commit counts as a retry: releasing
|
|
123
|
+
# identical code under two new minor versions is never wanted.)
|
|
124
|
+
retry=
|
|
125
|
+
merged=$(git tag --merged HEAD | grep -E "$semver" || true)
|
|
126
|
+
for t in $ours; do
|
|
127
|
+
base=$(printf '%s\n' $merged "$t" | sort -uV | grep -B1 -xF "$t" | grep -vxF "$t" || true)
|
|
128
|
+
if { [ -n "$base" ] && [ "$(bump "$base")" = "$t" ]; } ||
|
|
129
|
+
{ [ -z "$base" ] && [ "$t" = "$(first)" ]; }; then
|
|
130
|
+
retry=$t
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
133
|
+
fi
|
|
134
|
+
if [ -n "$retry" ]; then
|
|
135
|
+
echo "$SHA is already $retry; retrying its release and publish."
|
|
136
|
+
echo "tag=$retry" >> "$GITHUB_OUTPUT"
|
|
137
|
+
exit 0
|
|
138
|
+
fi
|
|
139
|
+
latest=$(git tag --list 'v*' | grep -E "$semver" | sort -V | tail -1 || true)
|
|
140
|
+
if [ -z "$latest" ]; then
|
|
141
|
+
next=$(first)
|
|
142
|
+
elif git merge-base --is-ancestor HEAD "$latest" &&
|
|
143
|
+
! printf '%s\n' $ours | grep -qxF "$latest"; then
|
|
144
|
+
echo "$SHA is already included in $latest; nothing to do."
|
|
145
|
+
exit 0
|
|
146
|
+
else
|
|
147
|
+
next=$(bump "$latest")
|
|
148
|
+
fi
|
|
149
|
+
echo "Releasing $next ($BUMP bump; previous: ${latest:-none})"
|
|
150
|
+
{
|
|
151
|
+
echo "tag=$next"
|
|
152
|
+
echo "new=true"
|
|
153
|
+
echo "previous=$latest"
|
|
154
|
+
echo "at_head=$newest"
|
|
155
|
+
} >> "$GITHUB_OUTPUT"
|
|
156
|
+
|
|
157
|
+
- name: Roll changelog and set version
|
|
158
|
+
id: changelog
|
|
159
|
+
if: steps.version.outputs.new == 'true'
|
|
160
|
+
env:
|
|
161
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
162
|
+
PREVIOUS: ${{ steps.version.outputs.previous }}
|
|
163
|
+
AT_HEAD: ${{ steps.version.outputs.at_head }}
|
|
164
|
+
run: |
|
|
165
|
+
notes="$RUNNER_TEMP/notes.md"
|
|
166
|
+
# True if a newer commit on main includes $SHA. Fails the step if main
|
|
167
|
+
# no longer contains $SHA at all (rewritten): releasing it would
|
|
168
|
+
# publish code that isn't on main, and skipping would hide that.
|
|
169
|
+
main_moved_past_sha() {
|
|
170
|
+
# Called inside `if`, where set -e is off: check the fetch by hand
|
|
171
|
+
# so a stale origin/main never decides a release.
|
|
172
|
+
if ! git fetch -q origin +refs/heads/main:refs/remotes/origin/main; then
|
|
173
|
+
echo "::error::Fetching main failed; can't tell whether it moved past $SHA."
|
|
174
|
+
exit 1
|
|
175
|
+
fi
|
|
176
|
+
[ "$(git rev-parse origin/main)" != "$SHA" ] || return 1
|
|
177
|
+
if ! git merge-base --is-ancestor "$SHA" origin/main; then
|
|
178
|
+
echo "::error::main no longer contains $SHA; not releasing $TAG."
|
|
179
|
+
exit 1
|
|
180
|
+
fi
|
|
181
|
+
}
|
|
182
|
+
# Releasing $SHA as is would leave its entries under [Unreleased]
|
|
183
|
+
# (they'd appear in the next release too) and miss the version files.
|
|
184
|
+
skip_release() {
|
|
185
|
+
echo "::notice::main moved past $SHA; not releasing $TAG here. The release for the newer merge includes this one (for a manual minor/major run, run it again)."
|
|
186
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
187
|
+
}
|
|
188
|
+
# A re-run after this changelog commit reached main but the release
|
|
189
|
+
# wasn't created: release that commit, with the notes it rolled,
|
|
190
|
+
# rather than rolling $SHA again (that push would be rejected).
|
|
191
|
+
git fetch -q origin +refs/heads/main:refs/remotes/origin/main
|
|
192
|
+
rolled=$(git log --format='%H%x09%P%x09%ae%x09%s' "$SHA..origin/main" |
|
|
193
|
+
awk -F '\t' -v sha="$SHA" -v bot="$BOT_EMAIL" -v subject="Update changelog for $TAG" \
|
|
194
|
+
'$2 == sha && $3 == bot && $4 == subject { print $1 }')
|
|
195
|
+
if [ -n "$rolled" ]; then
|
|
196
|
+
echo "Release commit for $TAG is already on main ($rolled); releasing that."
|
|
197
|
+
echo "sha=$rolled" >> "$GITHUB_OUTPUT"
|
|
198
|
+
if git show "$rolled:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md" 2> /dev/null &&
|
|
199
|
+
python3 .github/scripts/roll_changelog.py --notes "$RUNNER_TEMP/CHANGELOG.md" "$TAG" "$notes"; then
|
|
200
|
+
echo "notes=$notes" >> "$GITHUB_OUTPUT"
|
|
201
|
+
fi
|
|
202
|
+
exit 0
|
|
203
|
+
fi
|
|
204
|
+
if [ -f CHANGELOG.md ]; then
|
|
205
|
+
python3 .github/scripts/roll_changelog.py CHANGELOG.md "$TAG" "$PREVIOUS" \
|
|
206
|
+
"$(date -u +%Y-%m-%d)" "$notes" "$AT_HEAD"
|
|
207
|
+
fi
|
|
208
|
+
if [ -f .github/scripts/set_version.sh ]; then
|
|
209
|
+
bash .github/scripts/set_version.sh "${TAG#v}"
|
|
210
|
+
fi
|
|
211
|
+
# Stage everything, so files the hook creates or stages are included.
|
|
212
|
+
git add -A
|
|
213
|
+
if git diff --cached --quiet; then
|
|
214
|
+
echo "Nothing under [Unreleased] and no version files; using generated notes."
|
|
215
|
+
# Nothing to push, so main moving on isn't caught by the push below.
|
|
216
|
+
if main_moved_past_sha; then
|
|
217
|
+
skip_release
|
|
218
|
+
fi
|
|
219
|
+
exit 0
|
|
220
|
+
fi
|
|
221
|
+
git config user.name "github-actions[bot]"
|
|
222
|
+
git config user.email "$BOT_EMAIL"
|
|
223
|
+
git commit -qm "Update changelog for $TAG"
|
|
224
|
+
# Fast-forward only: if main moved past $SHA, leave it alone.
|
|
225
|
+
if git push origin HEAD:refs/heads/main; then
|
|
226
|
+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
227
|
+
if [ -s "$notes" ]; then
|
|
228
|
+
echo "notes=$notes" >> "$GITHUB_OUTPUT"
|
|
229
|
+
else
|
|
230
|
+
echo "Nothing under [Unreleased]; using generated notes."
|
|
231
|
+
fi
|
|
232
|
+
exit 0
|
|
233
|
+
fi
|
|
234
|
+
if ! main_moved_past_sha; then
|
|
235
|
+
echo "::error::Pushing the release commit for $TAG failed, and main hasn't moved."
|
|
236
|
+
exit 1
|
|
237
|
+
fi
|
|
238
|
+
skip_release
|
|
239
|
+
|
|
240
|
+
- name: Create release
|
|
241
|
+
if: steps.version.outputs.tag != '' && steps.changelog.outputs.skip != 'true'
|
|
242
|
+
env:
|
|
243
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
244
|
+
TARGET: ${{ steps.changelog.outputs.sha || env.SHA }}
|
|
245
|
+
NOTES: ${{ steps.changelog.outputs.notes }}
|
|
246
|
+
run: |
|
|
247
|
+
if ! gh release view "$TAG" > /dev/null 2>&1; then
|
|
248
|
+
if [ -n "$NOTES" ]; then notes=(--notes-file "$NOTES"); else notes=(--generate-notes); fi
|
|
249
|
+
gh release create "$TAG" --target "$TARGET" --title "$TAG" "${notes[@]}"
|
|
250
|
+
fi
|
|
251
|
+
# A release made with GITHUB_TOKEN doesn't fire publish.yml's release
|
|
252
|
+
# trigger, so start it directly on the new tag. Repos with nothing to
|
|
253
|
+
# publish have no publish.yml.
|
|
254
|
+
if [ -f .github/workflows/publish.yml ]; then
|
|
255
|
+
gh workflow run publish.yml --ref "$TAG"
|
|
256
|
+
fi
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **The content arranger.** `content/<name>.md` files are arranged into products by
|
|
14
|
+
`catalog.<name>.yaml` files: `defaults`, `rendering` (render variants), `strings`
|
|
15
|
+
(labels), `terms` (glossary column aliases), `groups`, `recipes` (shapes with
|
|
16
|
+
`$name` substitution) and `products` (an ordered `parts` list, a `theme`, and the
|
|
17
|
+
fields `status`, `version`, `variants`, `tags`). Nothing in the engine knows what the
|
|
18
|
+
content is about or what language it is in.
|
|
19
|
+
- **Generated parts.** `toc` (positional: lists every heading placed after it, with
|
|
20
|
+
page numbers resolved by Paged.js; settings `depth`, `pages`, `title`) and `terms`
|
|
21
|
+
(a glossary built from the content's term tables, with a label column when the
|
|
22
|
+
terms come from more than one labelled file).
|
|
23
|
+
- **Styled parts.** Any content part may carry `{style: <name>, toc: false}`; the
|
|
24
|
+
build wraps every part in `<div class="part part-<name> style-<style>">` for the
|
|
25
|
+
theme to style. A cover is a content file with `style: cover`. A content file
|
|
26
|
+
that is only comments (a placeholder) is skipped, so it never costs a page.
|
|
27
|
+
- **Rendering.** pandoc + pagedjs-cli (Paged.js in headless Chrome) for PDF variants,
|
|
28
|
+
pandoc EPUB 3 for `renderer: epub` variants, with size and palette css layers.
|
|
29
|
+
Incremental builds keyed on file mtimes plus a stamp of the catalog rows and the
|
|
30
|
+
imprenta version.
|
|
31
|
+
- **Targets.** `(catalog, product, variant)`; `imprenta build` takes `<catalog>`,
|
|
32
|
+
`<product>`, `<catalog>/<product>`, each optionally `.<variant>`, or nothing for
|
|
33
|
+
everything; `--tag` narrows to products carrying a tag. `imprenta affected --since`
|
|
34
|
+
maps changed paths to targets for incremental CI.
|
|
35
|
+
- **Validation.** `imprenta check` enforces the content rules (one leading H1, H2
|
|
36
|
+
sections, max H4, front matter) and the catalog rules (every name resolves, settings
|
|
37
|
+
per part kind, recipes, themes, publishing fields, rendering layers).
|
|
38
|
+
- **Catalog editing.** `imprenta.edit` writes catalogs through ruamel.yaml's
|
|
39
|
+
round-trip mode so comments survive.
|
|
40
|
+
- **The manager.** `imprenta manager` (extra `imprenta[tui]`): a Textual dashboard of
|
|
41
|
+
every product with a product screen for sync-free publishing fields, variant
|
|
42
|
+
subscriptions, builds and outputs.
|
|
43
|
+
- **API.** `imprenta.Project` as the entry point: catalogs, targets, build, check.
|
|
44
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Citation metadata (Citation File Format 1.2.0 — https://citation-file-format.github.io).
|
|
2
|
+
# `version`/`date-released` are omitted so this file never drifts from the actual release.
|
|
3
|
+
cff-version: 1.2.0
|
|
4
|
+
title: "imprenta: a content arranger and styler"
|
|
5
|
+
message: "If you use this software, please cite it using this metadata."
|
|
6
|
+
type: software
|
|
7
|
+
authors:
|
|
8
|
+
- family-names: McMeen
|
|
9
|
+
given-names: John
|
|
10
|
+
email: johnmcmeen@gmail.com
|
|
11
|
+
repository-code: "https://github.com/jmcmeen/imprenta"
|
|
12
|
+
license: MIT
|
imprenta-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# imprenta: conventions for Claude Code
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
A content arranger and styler. It is a Python package (`src/imprenta`) with a CLI (`imprenta`), an
|
|
6
|
+
optional Textual TUI (`imprenta[tui]`) and a public API (`imprenta.Project`).
|
|
7
|
+
The README describes the model; this file is about working on the code.
|
|
8
|
+
|
|
9
|
+
## Hard rules
|
|
10
|
+
|
|
11
|
+
- **Nothing content-specific.** No countries, languages, brands, slang, guides
|
|
12
|
+
or books in code, comments, docstrings, tests or docs. Examples use neutral
|
|
13
|
+
names (`intro`, `alpha`, `almanac`, `leaflet`, `release`). What a tag means,
|
|
14
|
+
where outputs are published, what a glossary column is called: all of that is
|
|
15
|
+
the content repo's business and lives in its catalog or its own scripts.
|
|
16
|
+
- **The engine never injects or shifts headings.** What is in a content file
|
|
17
|
+
is what renders. Themes style; they do not restructure.
|
|
18
|
+
- **A catalog has exactly these sections:** `defaults`, `rendering`,
|
|
19
|
+
`strings`, `terms`, `groups`, `recipes`, `products`. Adding a field is an
|
|
20
|
+
API change: update `catalog.py`, `check.py`, the README and the CHANGELOG
|
|
21
|
+
together.
|
|
22
|
+
- **Everything in a parts list is a name.** No sigils, no catch-all. Names
|
|
23
|
+
resolve as generated part, group, content file, in that order.
|
|
24
|
+
- **Catalogs are written only through `imprenta.edit`** (ruamel round-trip).
|
|
25
|
+
PyYAML's dump drops comments. ruamel attaches a comment to the key before
|
|
26
|
+
it, so popping a key can take the next comment with it.
|
|
27
|
+
- **Paged.js matches break rules by selector**, not computed style. To keep an
|
|
28
|
+
element from breaking, exclude it from the rule; a later `break-before:
|
|
29
|
+
auto` does not undo an earlier `page`.
|
|
30
|
+
|
|
31
|
+
## Layout
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
src/imprenta/
|
|
35
|
+
project.py Project: root, dirs, catalogs (cached on mtime), content names, targets, build, check
|
|
36
|
+
catalog.py Catalog: sections, rendering, product_def (recipes + $name), plan, resolve_parts, render_row
|
|
37
|
+
content.py front matter, comments, headings, word counts, css-safe names
|
|
38
|
+
parts.py generated parts (toc marker, terms glossary), part wrapping, assemble
|
|
39
|
+
render.py pandoc / pagedjs-cli / Chrome, doctor, asset copying, the packaged toc.lua
|
|
40
|
+
build.py freshness (inputs + stamp), building one target
|
|
41
|
+
targets.py Target, all_targets, affected_targets, changed_since, spec parsing
|
|
42
|
+
check.py the validator (content rules + catalog rules)
|
|
43
|
+
edit.py round-trip catalog editing
|
|
44
|
+
cli.py argparse entry point; tui.py the manager (textual, optional)
|
|
45
|
+
data/toc.lua the pandoc filter behind the positional table of contents
|
|
46
|
+
tests/ pytest; conftest builds a two-catalog sample project in tmp_path
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Working here
|
|
50
|
+
|
|
51
|
+
- `make install` (uv sync with the dev and TUI extras), `make test`, `make
|
|
52
|
+
lint`, `make fmt`, `make check` (what CI runs). Python ≥ 3.10.
|
|
53
|
+
- Versions come from `vX.Y.Z` tags via hatch-vcs. `release.yml` bumps the patch
|
|
54
|
+
on every green merge to main and rolls `CHANGELOG.md`'s Unreleased section
|
|
55
|
+
into the release; a minor or major bump is a manual run of that workflow.
|
|
56
|
+
Add every user-visible change under `## [Unreleased]` in the same commit.
|
|
57
|
+
- `publish.yml` publishes to PyPI on a release (trusted publishing, environment
|
|
58
|
+
`pypi`). Content repos pin `imprenta==X.Y.Z`.
|
|
59
|
+
- Tests never call pandoc or Chrome. Rendering is exercised from a content repo
|
|
60
|
+
(`imprenta build`), the logic around it is what the suite covers.
|
|
61
|
+
- The TUI builds through `python -m imprenta build …` in a worker thread so
|
|
62
|
+
its output streams into the log pane; it never imports make.
|
imprenta-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John McMeen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|