docmost-cli 0.4.0__tar.gz → 0.7.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.
Files changed (138) hide show
  1. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/.claude/skills/docmost/SKILL.md +18 -4
  2. docmost_cli-0.7.0/.github/workflows/ci.yml +170 -0
  3. docmost_cli-0.7.0/.github/workflows/release.yml +231 -0
  4. docmost_cli-0.7.0/CHANGELOG.md +371 -0
  5. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/CLAUDE.md +56 -3
  6. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/PKG-INFO +45 -14
  7. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/README.md +44 -12
  8. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/SPECIFICATION.md +209 -51
  9. docmost_cli-0.7.0/man/man1/docmost-cli-attachment.1 +157 -0
  10. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-comment.1 +37 -3
  11. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-config.1 +32 -2
  12. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-page.1 +165 -15
  13. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-search.1 +30 -5
  14. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-space.1 +37 -3
  15. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-sync.1 +22 -11
  16. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli-user.1 +4 -1
  17. docmost_cli-0.7.0/man/man1/docmost-cli-workspace.1 +105 -0
  18. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/man/man1/docmost-cli.1 +57 -5
  19. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/pyproject.toml +6 -2
  20. docmost_cli-0.7.0/release-notes.md +62 -0
  21. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/__init__.py +1 -1
  22. docmost_cli-0.7.0/src/docmost_cli/api/attachments.py +97 -0
  23. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/auth.py +22 -4
  24. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/client.py +40 -25
  25. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/comments.py +13 -3
  26. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/pages.py +279 -61
  27. docmost_cli-0.7.0/src/docmost_cli/api/pagination.py +298 -0
  28. docmost_cli-0.7.0/src/docmost_cli/api/position.py +287 -0
  29. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/spaces.py +23 -7
  30. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/users.py +4 -4
  31. docmost_cli-0.7.0/src/docmost_cli/cli/_list_opts.py +248 -0
  32. docmost_cli-0.7.0/src/docmost_cli/cli/attachment.py +98 -0
  33. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/comment.py +32 -8
  34. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/config_cmd.py +83 -31
  35. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/main.py +70 -32
  36. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/page.py +216 -47
  37. docmost_cli-0.7.0/src/docmost_cli/cli/search.py +56 -0
  38. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/space.py +30 -7
  39. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/sync_cmd.py +33 -27
  40. docmost_cli-0.7.0/src/docmost_cli/cli/user.py +30 -0
  41. docmost_cli-0.7.0/src/docmost_cli/cli/workspace.py +66 -0
  42. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/config/settings.py +3 -0
  43. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/config/store.py +17 -12
  44. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/convert/prosemirror_to_md.py +10 -2
  45. docmost_cli-0.7.0/src/docmost_cli/models/__init__.py +5 -0
  46. docmost_cli-0.7.0/src/docmost_cli/models/common.py +47 -0
  47. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/output/__init__.py +10 -0
  48. docmost_cli-0.7.0/src/docmost_cli/output/formatter.py +166 -0
  49. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/output/tree.py +2 -3
  50. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/__init__.py +4 -0
  51. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/diff.py +42 -3
  52. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/manifest.py +30 -8
  53. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/pull.py +17 -19
  54. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/push.py +142 -94
  55. docmost_cli-0.7.0/tests/test_api/test_attachments.py +122 -0
  56. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_auth.py +54 -0
  57. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_client.py +74 -0
  58. docmost_cli-0.7.0/tests/test_api/test_pages.py +504 -0
  59. docmost_cli-0.7.0/tests/test_api/test_pagination.py +249 -0
  60. docmost_cli-0.7.0/tests/test_api/test_position.py +127 -0
  61. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_spaces.py +67 -0
  62. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_attachment_commands.py +48 -0
  63. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_comment_commands.py +42 -0
  64. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_config_commands.py +31 -0
  65. docmost_cli-0.7.0/tests/test_cli/test_list_fields.py +139 -0
  66. docmost_cli-0.7.0/tests/test_cli/test_main.py +60 -0
  67. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_page_commands.py +351 -16
  68. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_sync_commands.py +69 -0
  69. docmost_cli-0.7.0/tests/test_cli/test_user_commands.py +55 -0
  70. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_workspace_commands.py +50 -10
  71. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_config/test_store.py +35 -0
  72. docmost_cli-0.7.0/tests/test_docs/test_man_pages.py +88 -0
  73. docmost_cli-0.7.0/tests/test_output/test_formatter.py +148 -0
  74. docmost_cli-0.7.0/tests/test_sync/__init__.py +0 -0
  75. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_sync/test_diff.py +90 -1
  76. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_sync/test_push.py +126 -92
  77. docmost_cli-0.4.0/CHANGELOG.md +0 -102
  78. docmost_cli-0.4.0/man/man1/docmost-cli-attachment.1 +0 -57
  79. docmost_cli-0.4.0/man/man1/docmost-cli-workspace.1 +0 -68
  80. docmost_cli-0.4.0/src/docmost_cli/api/attachments.py +0 -30
  81. docmost_cli-0.4.0/src/docmost_cli/api/pagination.py +0 -94
  82. docmost_cli-0.4.0/src/docmost_cli/cli/attachment.py +0 -30
  83. docmost_cli-0.4.0/src/docmost_cli/cli/search.py +0 -33
  84. docmost_cli-0.4.0/src/docmost_cli/cli/user.py +0 -25
  85. docmost_cli-0.4.0/src/docmost_cli/cli/workspace.py +0 -40
  86. docmost_cli-0.4.0/src/docmost_cli/models/__init__.py +0 -3
  87. docmost_cli-0.4.0/src/docmost_cli/models/common.py +0 -3
  88. docmost_cli-0.4.0/src/docmost_cli/output/formatter.py +0 -85
  89. docmost_cli-0.4.0/tests/test_api/test_attachments.py +0 -45
  90. docmost_cli-0.4.0/tests/test_api/test_pages.py +0 -289
  91. docmost_cli-0.4.0/tests/test_api/test_pagination.py +0 -62
  92. docmost_cli-0.4.0/tests/test_cli/test_user_commands.py +0 -28
  93. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/.claude/skills/docmost/examples.md +0 -0
  94. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/.gitignore +0 -0
  95. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/LICENSE +0 -0
  96. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/__main__.py +0 -0
  97. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/__init__.py +0 -0
  98. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/search.py +0 -0
  99. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/api/workspace.py +0 -0
  100. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/cli/__init__.py +0 -0
  101. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/config/__init__.py +0 -0
  102. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/convert/__init__.py +0 -0
  103. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/py.typed +0 -0
  104. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/src/docmost_cli/sync/frontmatter.py +0 -0
  105. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/__init__.py +0 -0
  106. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/conftest.py +0 -0
  107. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/code_block.json +0 -0
  108. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/code_block.md +0 -0
  109. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/empty_doc.json +0 -0
  110. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/empty_doc.md +0 -0
  111. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/headings_and_text.json +0 -0
  112. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/headings_and_text.md +0 -0
  113. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/lists.json +0 -0
  114. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/lists.md +0 -0
  115. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/marks.json +0 -0
  116. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/marks.md +0 -0
  117. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/simple_paragraph.json +0 -0
  118. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/simple_paragraph.md +0 -0
  119. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/table.json +0 -0
  120. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/fixtures/table.md +0 -0
  121. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/__init__.py +0 -0
  122. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_comments.py +0 -0
  123. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_search.py +0 -0
  124. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_users.py +0 -0
  125. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_api/test_workspace.py +0 -0
  126. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/__init__.py +0 -0
  127. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_search_commands.py +0 -0
  128. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_cli/test_space_commands.py +0 -0
  129. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_config/__init__.py +0 -0
  130. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_config/test_settings.py +0 -0
  131. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_convert/__init__.py +0 -0
  132. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_convert/test_prosemirror_to_md.py +0 -0
  133. {docmost_cli-0.4.0/tests/test_output → docmost_cli-0.7.0/tests/test_docs}/__init__.py +0 -0
  134. {docmost_cli-0.4.0/tests/test_sync → docmost_cli-0.7.0/tests/test_output}/__init__.py +0 -0
  135. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_output/test_tree.py +0 -0
  136. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_sync/test_frontmatter.py +0 -0
  137. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_sync/test_manifest.py +0 -0
  138. {docmost_cli-0.4.0 → docmost_cli-0.7.0}/tests/test_sync/test_pull.py +0 -0
@@ -96,19 +96,33 @@ docmost-cli page export <page-id> --format md
96
96
 
97
97
  1. Get current content: `docmost-cli page get <page-id>`
98
98
  2. Modify the Markdown content as needed
99
- 3. Update: `docmost-cli page update <page-id> --title "Updated Title"`
100
- 4. Note: Content update via REST may not be available on Community edition
99
+ 3. Update: `docmost-cli page update <page-id> --file updated.md`
100
+ 4. Content updates are applied in place the page keeps its ID, slug, history
101
+ and inbound links. Works on Community and Enterprise alike, from Docmost
102
+ v0.71. Use `--append` / `--prepend` to add without replacing.
101
103
 
102
104
  ## Output Format
103
105
 
104
106
  - **Content commands** (`page get`): Raw Markdown to stdout — directly usable
105
- - **List commands** (`--json`): JSON array parse with standard JSON tools
107
+ - **List commands** (`--json`): JSON array of **complete server objects** every
108
+ field the API returned, not a subset. For pages that means `id`, `slugId`,
109
+ `title`, `icon`, `position`, `parentPageId`, `spaceId`, `creatorId`,
110
+ `lastUpdatedById`, `isLocked`, `createdAt`, `updatedAt` and more. Page
111
+ *content* is not included; fetch it with `page get <id>`.
112
+ Pagination is followed automatically, so the array is complete; add
113
+ `--envelope` if you need `hasNextPage` / `nextCursor`
114
+ - **Narrow the output** with `--fields id,title,updatedAt` when you only need a
115
+ few keys — worth doing to keep context small, since `--json` is now much
116
+ larger. It works in JSON and table mode, and an unknown name errors with the
117
+ list of fields the server actually returned
118
+ - **Single-item commands** (`user me`, `workspace info`): add `--json` for the
119
+ complete object
106
120
  - **Write commands** (`page create`, `delete`): Page ID to stdout, confirmation to stderr
107
121
  - **Always use `--json`** for list/search commands when you need to parse the output programmatically
108
122
 
109
123
  ## Tips
110
124
 
111
- - Use `--json` flag on list commands to get parseable output
125
+ - Use `--json` on list commands to get parseable output, plus `--fields` to keep it small
112
126
  - Page IDs are UUIDs like `019a2a69-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
113
127
  - Space slugs are lowercase alphanumeric (e.g., `engineering`, `devtest`)
114
128
  - Use `-y` flag to skip confirmation prompts for automated workflows
@@ -0,0 +1,170 @@
1
+ name: CI
2
+
3
+ # Uses only first-party actions, and installs with pip rather than uv. The
4
+ # project prefers uv locally (see CLAUDE.md), but this runner could not resolve
5
+ # `astral-sh/setup-uv@v9` at all, and pip is the documented fallback — a CI file
6
+ # that reliably runs beats a marginally faster one.
7
+ #
8
+ # Pins are the current majors, which target Node 24. Node 20 is deprecated and
9
+ # is being force-migrated on the runners, so checkout@v4 / setup-python@v5 would
10
+ # have had a shelf life.
11
+ #
12
+ # actionlint is downloaded and checksummed rather than run as an action, for the
13
+ # same reason: one less third-party resolution in the path.
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+ pull_request:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ concurrency:
24
+ group: ${{ github.workflow }}-${{ github.ref }}
25
+ cancel-in-progress: true
26
+
27
+ jobs:
28
+ test:
29
+ name: test (py${{ matrix.python-version }})
30
+ runs-on: ubuntu-latest
31
+ strategy:
32
+ # Report every interpreter, rather than hiding 3.13 behind a 3.11 failure.
33
+ fail-fast: false
34
+ matrix:
35
+ python-version: ["3.11", "3.12", "3.13"]
36
+ steps:
37
+ - uses: actions/checkout@v7
38
+
39
+ - uses: actions/setup-python@v6
40
+ with:
41
+ python-version: ${{ matrix.python-version }}
42
+ cache: pip
43
+ cache-dependency-path: pyproject.toml
44
+
45
+ - name: Install
46
+ run: |
47
+ python -m pip install --upgrade pip
48
+ python -m pip install -e ".[dev]"
49
+
50
+ - name: Verify the interpreter matches the matrix
51
+ # Without this, a misconfigured setup step would silently run every
52
+ # matrix entry on the runner's default Python and report three passes
53
+ # for one version.
54
+ run: |
55
+ python - <<'PY'
56
+ import sys
57
+ actual = "{}.{}".format(*sys.version_info[:2])
58
+ expected = "${{ matrix.python-version }}"
59
+ assert actual == expected, f"expected Python {expected}, got {actual}"
60
+ print("Python", sys.version)
61
+ PY
62
+
63
+ - name: Test
64
+ run: python -m pytest tests/ -q
65
+
66
+ quality:
67
+ name: lint and type-check
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - uses: actions/checkout@v7
71
+
72
+ - uses: actions/setup-python@v6
73
+ with:
74
+ python-version: "3.11"
75
+ cache: pip
76
+ cache-dependency-path: pyproject.toml
77
+
78
+ - name: Install
79
+ run: |
80
+ python -m pip install --upgrade pip
81
+ python -m pip install -e ".[dev]"
82
+
83
+ - name: Ruff lint
84
+ run: python -m ruff check src/ tests/
85
+
86
+ - name: Ruff format
87
+ run: python -m ruff format --check src/ tests/
88
+
89
+ # Runs once, not per matrix entry: pyproject pins mypy's
90
+ # python_version = "3.11", so the result is interpreter-independent.
91
+ - name: Mypy (strict)
92
+ run: python -m mypy src/
93
+
94
+ workflows:
95
+ name: lint workflows
96
+ runs-on: ubuntu-latest
97
+ # Bump both together: the checksum is for the linux_amd64 tarball of this
98
+ # exact version, published on the actionlint releases page.
99
+ env:
100
+ ACTIONLINT_VERSION: 1.7.12
101
+ ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
102
+ steps:
103
+ - uses: actions/checkout@v7
104
+
105
+ - name: Install actionlint
106
+ run: |
107
+ set -euo pipefail
108
+ curl -fsSL -o actionlint.tar.gz \
109
+ "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
110
+ echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum --check --strict
111
+ tar xzf actionlint.tar.gz actionlint
112
+
113
+ - name: Lint the workflows
114
+ # actionlint only shell-lints `run:` blocks when shellcheck is on PATH,
115
+ # and quietly skips them when it is not. Assert it, rather than trust the
116
+ # runner image to keep shipping it and lose half the coverage in silence.
117
+ run: |
118
+ set -euo pipefail
119
+ command -v shellcheck
120
+ ./actionlint -color
121
+
122
+ build:
123
+ name: build
124
+ runs-on: ubuntu-latest
125
+ steps:
126
+ - uses: actions/checkout@v7
127
+
128
+ - uses: actions/setup-python@v6
129
+ with:
130
+ python-version: "3.11"
131
+
132
+ - name: Build sdist and wheel
133
+ run: |
134
+ python -m pip install --upgrade pip build
135
+ python -m build
136
+
137
+ - name: Verify the built version matches __version__
138
+ # The version is dynamic: hatch reads it out of src/docmost_cli/__init__.py.
139
+ # If that regex ever stops matching, the build silently produces the wrong
140
+ # version rather than failing.
141
+ run: |
142
+ python - <<'PY'
143
+ import pathlib, re
144
+
145
+ source = pathlib.Path("src/docmost_cli/__init__.py").read_text()
146
+ match = re.search(r'__version__ = "([^"]+)"', source)
147
+ assert match, "no __version__ in src/docmost_cli/__init__.py"
148
+ expected = match.group(1)
149
+
150
+ built = sorted(p.name for p in pathlib.Path("dist").glob("*.whl"))
151
+ assert built, "no wheel produced"
152
+ assert any(f"-{expected}-" in name for name in built), (
153
+ f"__version__ is {expected} but built {built}"
154
+ )
155
+ print(f"version {expected} OK: {built}")
156
+ PY
157
+
158
+ - name: Verify the man pages ship in the wheel
159
+ # They are installed via [tool.hatch.build.targets.wheel.shared-data],
160
+ # which breaks quietly if the path ever moves.
161
+ run: |
162
+ python - <<'PY'
163
+ import pathlib, zipfile
164
+
165
+ wheel = next(iter(pathlib.Path("dist").glob("*.whl")))
166
+ names = zipfile.ZipFile(wheel).namelist()
167
+ pages = [n for n in names if n.endswith(".1") and "share/man/man1" in n]
168
+ assert len(pages) == 10, f"expected 10 man pages in the wheel, found {len(pages)}"
169
+ print(f"{len(pages)} man pages OK")
170
+ PY
@@ -0,0 +1,231 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI and cuts a GitHub Release when a v* tag is pushed.
4
+ #
5
+ # Authentication is PyPI Trusted Publishing (OIDC) — no API token is stored in
6
+ # this repository. One-time setup on pypi.org, under the project's
7
+ # "Publishing" settings, add a trusted publisher:
8
+ #
9
+ # Owner: glinhard
10
+ # Repository: docmost-cli
11
+ # Workflow name: release.yml
12
+ # Environment: pypi
13
+ #
14
+ # Dry runs: trigger this workflow manually (Actions -> Release -> Run workflow)
15
+ # to exercise the whole chain — version guard, build, artifact checks, and
16
+ # action resolution — without uploading anything anywhere.
17
+
18
+ on:
19
+ push:
20
+ tags: ["v*"]
21
+ workflow_dispatch:
22
+ inputs:
23
+ dry-run:
24
+ description: "Build and verify only; do not publish"
25
+ type: boolean
26
+ default: true
27
+
28
+ permissions:
29
+ contents: read
30
+
31
+ jobs:
32
+ verify:
33
+ name: verify and build
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v7
37
+
38
+ - uses: actions/setup-python@v6
39
+ with:
40
+ python-version: "3.11"
41
+ cache: pip
42
+ cache-dependency-path: pyproject.toml
43
+
44
+ - name: Install
45
+ run: |
46
+ python -m pip install --upgrade pip build twine
47
+ python -m pip install -e ".[dev]"
48
+
49
+ - name: Full test suite
50
+ # The tag is the last chance to catch a regression before it is
51
+ # published immutably: PyPI does not allow re-uploading a version.
52
+ run: python -m pytest tests/ -q
53
+
54
+ - name: Verify the tag matches __version__
55
+ if: startsWith(github.ref, 'refs/tags/')
56
+ run: |
57
+ python - <<'PY'
58
+ import os, pathlib, re
59
+
60
+ source = pathlib.Path("src/docmost_cli/__init__.py").read_text()
61
+ version = re.search(r'__version__ = "([^"]+)"', source).group(1)
62
+ tag = os.environ["GITHUB_REF_NAME"]
63
+ assert tag == f"v{version}", f"tag {tag} does not match __version__ {version}"
64
+ print(f"tag {tag} matches __version__ {version}")
65
+ PY
66
+
67
+ - name: Verify this version is not already on PyPI
68
+ # PyPI rejects a duplicate upload anyway; failing here gives a clearer
69
+ # message than a 400 from the publish step.
70
+ run: |
71
+ python - <<'PY'
72
+ import json, pathlib, re, urllib.request
73
+
74
+ source = pathlib.Path("src/docmost_cli/__init__.py").read_text()
75
+ version = re.search(r'__version__ = "([^"]+)"', source).group(1)
76
+ try:
77
+ with urllib.request.urlopen(
78
+ "https://pypi.org/pypi/docmost-cli/json", timeout=30
79
+ ) as response:
80
+ released = set(json.load(response)["releases"])
81
+ except Exception as exc: # noqa: BLE001 - never block a release on this
82
+ print(f"could not reach PyPI ({exc}); skipping the duplicate check")
83
+ else:
84
+ assert version not in released, (
85
+ f"{version} is already on PyPI; bump __version__ before tagging"
86
+ )
87
+ print(f"{version} is not yet on PyPI (has {sorted(released)})")
88
+ PY
89
+
90
+ - name: Build sdist and wheel
91
+ run: python -m build
92
+
93
+ - name: Check the distributions
94
+ run: python -m twine check dist/*
95
+
96
+ - name: Verify the wheel contents
97
+ run: |
98
+ python - <<'PY'
99
+ import pathlib, re, zipfile
100
+
101
+ source = pathlib.Path("src/docmost_cli/__init__.py").read_text()
102
+ version = re.search(r'__version__ = "([^"]+)"', source).group(1)
103
+
104
+ wheel = next(iter(pathlib.Path("dist").glob("*.whl")))
105
+ assert f"-{version}-" in wheel.name, f"built {wheel.name}, expected {version}"
106
+
107
+ names = zipfile.ZipFile(wheel).namelist()
108
+ pages = [n for n in names if n.endswith(".1") and "share/man/man1" in n]
109
+ assert len(pages) == 10, f"expected 10 man pages, found {len(pages)}"
110
+
111
+ metadata = next(n for n in names if n.endswith("METADATA"))
112
+ text = zipfile.ZipFile(wheel).read(metadata).decode()
113
+ assert "License-Expression: AGPL-3.0-or-later" in text, "missing licence expression"
114
+ assert "Classifier: License ::" not in text, (
115
+ "deprecated License :: classifier is back; PEP 639 replaces it "
116
+ "with the license expression"
117
+ )
118
+ print(f"{wheel.name}: {len(pages)} man pages, licence metadata OK")
119
+ PY
120
+
121
+ # upload@v7 and download@v8 are a matched pair and should move together:
122
+ # v7 added the option to upload files as-is, and v8 is the major that
123
+ # stopped assuming every download is a zip.
124
+ #
125
+ # Keep `archive` at its default of true. Uploading as-is takes only a
126
+ # single file, and this step ships two — the wheel and the sdist.
127
+ - uses: actions/upload-artifact@v7
128
+ with:
129
+ name: dist
130
+ path: dist/
131
+
132
+ publish:
133
+ name: publish to PyPI
134
+ needs: verify
135
+ runs-on: ubuntu-latest
136
+ # The job itself also runs on a manual dispatch, so a dry run resolves and
137
+ # downloads every action here — including the third-party publish action.
138
+ # That matters: `astral-sh/setup-uv` turned out not to resolve on these
139
+ # runners at all, and a release is the worst moment to discover that.
140
+ environment:
141
+ name: pypi
142
+ url: https://pypi.org/project/docmost-cli/
143
+ permissions:
144
+ id-token: write # required for Trusted Publishing
145
+ steps:
146
+ - uses: actions/download-artifact@v8
147
+ with:
148
+ name: dist
149
+ path: dist/
150
+
151
+ - name: Publish
152
+ # Uploading is gated on the ref being a tag, not on the dry-run input.
153
+ # A dispatch always runs against a branch, so no manual invocation can
154
+ # publish, whatever inputs it is given.
155
+ if: startsWith(github.ref, 'refs/tags/')
156
+ uses: pypa/gh-action-pypi-publish@release/v1
157
+
158
+ - name: Dry run summary
159
+ if: ${{ !startsWith(github.ref, 'refs/tags/') }}
160
+ run: |
161
+ echo "Dry run: actions resolved and artifacts built, nothing published."
162
+ echo "Push a v* tag to publish for real."
163
+ ls -l dist/
164
+
165
+ github-release:
166
+ name: GitHub Release
167
+ needs: publish
168
+ runs-on: ubuntu-latest
169
+ if: startsWith(github.ref, 'refs/tags/')
170
+ permissions:
171
+ contents: write # required to create the release
172
+ steps:
173
+ - uses: actions/checkout@v7
174
+
175
+ - uses: actions/download-artifact@v8
176
+ with:
177
+ name: dist
178
+ path: dist/
179
+
180
+ - name: Extract this version's CHANGELOG section
181
+ run: |
182
+ python - <<'PY'
183
+ import os, pathlib, re
184
+
185
+ version = os.environ["GITHUB_REF_NAME"].removeprefix("v")
186
+ changelog = pathlib.Path("CHANGELOG.md").read_text()
187
+ # Everything from this version's heading up to the next "## " heading.
188
+ match = re.search(
189
+ rf"^## {re.escape(version)}.*?$(.*?)(?=^## )",
190
+ changelog,
191
+ re.MULTILINE | re.DOTALL,
192
+ )
193
+ body = match.group(1).strip() if match else f"See CHANGELOG.md for {version}."
194
+ pathlib.Path("release-notes.md").write_text(body + "\n")
195
+ print(body[:500])
196
+ PY
197
+
198
+ - name: Create or update the release
199
+ # The tag can arrive two ways: `git push origin v0.6.0`, or the GitHub
200
+ # web UI's "Draft a new release" flow, which creates the tag *and* the
201
+ # release before this job runs. Handle both, so neither route ends in a
202
+ # red job.
203
+ env:
204
+ GH_TOKEN: ${{ github.token }}
205
+ run: |
206
+ set -euo pipefail
207
+ tag="$GITHUB_REF_NAME"
208
+
209
+ if gh release view "$tag" >/dev/null 2>&1; then
210
+ echo "Release $tag already exists; attaching artifacts."
211
+ gh release upload "$tag" dist/* --clobber
212
+
213
+ # Only fill in notes if whoever created it left them empty —
214
+ # never overwrite notes a human wrote by hand.
215
+ #
216
+ # `body` is a nullable string, and jq renders a null as the four
217
+ # characters "null" — non-empty, so without the `// ""` this test
218
+ # reads an empty release as one a human had already written.
219
+ if [ -z "$(gh release view "$tag" --json body --jq '.body // ""' | tr -d '[:space:]')" ]; then
220
+ echo "Release body is empty; filling it from CHANGELOG.md."
221
+ gh release edit "$tag" --notes-file release-notes.md
222
+ else
223
+ echo "Release already has notes; leaving them alone."
224
+ fi
225
+ else
226
+ echo "Creating release $tag."
227
+ gh release create "$tag" \
228
+ --title "$tag" \
229
+ --notes-file release-notes.md \
230
+ dist/*
231
+ fi