promptmend 0.19.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 (132) hide show
  1. promptmend-0.19.0/.env.example +118 -0
  2. promptmend-0.19.0/.gitattributes +3 -0
  3. promptmend-0.19.0/.github/ISSUE_TEMPLATE/bug_report.yml +51 -0
  4. promptmend-0.19.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. promptmend-0.19.0/.github/ISSUE_TEMPLATE/feature_request.yml +21 -0
  6. promptmend-0.19.0/.github/PULL_REQUEST_TEMPLATE.md +17 -0
  7. promptmend-0.19.0/.github/copilot-instructions.md +23 -0
  8. promptmend-0.19.0/.github/dependabot.yml +32 -0
  9. promptmend-0.19.0/.github/workflows/release.yml +287 -0
  10. promptmend-0.19.0/.github/workflows/secret-scan.yml +25 -0
  11. promptmend-0.19.0/.github/workflows/test.yml +93 -0
  12. promptmend-0.19.0/.gitignore +19 -0
  13. promptmend-0.19.0/.gitleaksignore +4 -0
  14. promptmend-0.19.0/.pre-commit-config.yaml +36 -0
  15. promptmend-0.19.0/CHANGELOG.md +1062 -0
  16. promptmend-0.19.0/CLAUDE.md +501 -0
  17. promptmend-0.19.0/CONTRIBUTING.md +518 -0
  18. promptmend-0.19.0/LICENSE +21 -0
  19. promptmend-0.19.0/PKG-INFO +1099 -0
  20. promptmend-0.19.0/README.md +1083 -0
  21. promptmend-0.19.0/SECURITY.md +38 -0
  22. promptmend-0.19.0/docs/benchmark.md +215 -0
  23. promptmend-0.19.0/docs/interface.svg +210 -0
  24. promptmend-0.19.0/docs/prompt-candidates/PLAN.md +237 -0
  25. promptmend-0.19.0/espanso/match/prompts-core.yml +8 -0
  26. promptmend-0.19.0/espanso/match/prompts-llm.yml +136 -0
  27. promptmend-0.19.0/espanso/match/prompts-template.yml +54 -0
  28. promptmend-0.19.0/pyproject.toml +86 -0
  29. promptmend-0.19.0/scripts/bench_models.py +1785 -0
  30. promptmend-0.19.0/scripts/brew_formula.py +370 -0
  31. promptmend-0.19.0/scripts/check_tool_lock.py +87 -0
  32. promptmend-0.19.0/scripts/check_wheel.py +180 -0
  33. promptmend-0.19.0/scripts/install_macos.sh +63 -0
  34. promptmend-0.19.0/scripts/install_windows.ps1 +77 -0
  35. promptmend-0.19.0/scripts/release_notes.py +108 -0
  36. promptmend-0.19.0/scripts/update_match_history.py +145 -0
  37. promptmend-0.19.0/src/promptmend/__init__.py +7 -0
  38. promptmend-0.19.0/src/promptmend/assets.py +83 -0
  39. promptmend-0.19.0/src/promptmend/cli.py +688 -0
  40. promptmend-0.19.0/src/promptmend/clipboard_guard.py +163 -0
  41. promptmend-0.19.0/src/promptmend/commands/__init__.py +6 -0
  42. promptmend-0.19.0/src/promptmend/commands/common.py +246 -0
  43. promptmend-0.19.0/src/promptmend/commands/doctor.py +63 -0
  44. promptmend-0.19.0/src/promptmend/commands/profiles.py +91 -0
  45. promptmend-0.19.0/src/promptmend/commands/settings.py +424 -0
  46. promptmend-0.19.0/src/promptmend/commands/setup.py +462 -0
  47. promptmend-0.19.0/src/promptmend/commands/ui.py +46 -0
  48. promptmend-0.19.0/src/promptmend/commands/usage.py +219 -0
  49. promptmend-0.19.0/src/promptmend/config.py +883 -0
  50. promptmend-0.19.0/src/promptmend/config_files.py +469 -0
  51. promptmend-0.19.0/src/promptmend/config_store.py +920 -0
  52. promptmend-0.19.0/src/promptmend/deploy.py +776 -0
  53. promptmend-0.19.0/src/promptmend/doctor.py +699 -0
  54. promptmend-0.19.0/src/promptmend/factory.py +261 -0
  55. promptmend-0.19.0/src/promptmend/gate.py +82 -0
  56. promptmend-0.19.0/src/promptmend/history.py +1050 -0
  57. promptmend-0.19.0/src/promptmend/match_history.py +51 -0
  58. promptmend-0.19.0/src/promptmend/previous_install.py +377 -0
  59. promptmend-0.19.0/src/promptmend/profiles.py +168 -0
  60. promptmend-0.19.0/src/promptmend/prompt_builder.py +216 -0
  61. promptmend-0.19.0/src/promptmend/prompts/default.md +221 -0
  62. promptmend-0.19.0/src/promptmend/prompts/general.md +13 -0
  63. promptmend-0.19.0/src/promptmend/providers/anthropic.py +76 -0
  64. promptmend-0.19.0/src/promptmend/providers/base.py +398 -0
  65. promptmend-0.19.0/src/promptmend/providers/ollama.py +59 -0
  66. promptmend-0.19.0/src/promptmend/providers/openai_compatible.py +84 -0
  67. promptmend-0.19.0/src/promptmend/providers/usage.py +243 -0
  68. promptmend-0.19.0/src/promptmend/recorder.py +120 -0
  69. promptmend-0.19.0/src/promptmend/redaction.py +473 -0
  70. promptmend-0.19.0/src/promptmend/relocate.py +144 -0
  71. promptmend-0.19.0/src/promptmend/smoke.py +132 -0
  72. promptmend-0.19.0/src/promptmend/tui/__init__.py +7 -0
  73. promptmend-0.19.0/src/promptmend/tui/app.py +223 -0
  74. promptmend-0.19.0/src/promptmend/tui/brand.py +69 -0
  75. promptmend-0.19.0/src/promptmend/tui/home.py +187 -0
  76. promptmend-0.19.0/src/promptmend/tui/intro.py +90 -0
  77. promptmend-0.19.0/src/promptmend/tui/modals.py +163 -0
  78. promptmend-0.19.0/src/promptmend/tui/panes.py +1120 -0
  79. promptmend-0.19.0/src/promptmend/tui/previous.py +398 -0
  80. promptmend-0.19.0/src/promptmend/tui/state.py +79 -0
  81. promptmend-0.19.0/src/promptmend/tui/teach.py +113 -0
  82. promptmend-0.19.0/tests/bench_module.py +53 -0
  83. promptmend-0.19.0/tests/conftest.py +258 -0
  84. promptmend-0.19.0/tests/golden/no-args-help.txt +17 -0
  85. promptmend-0.19.0/tests/snapshots/about.svg +216 -0
  86. promptmend-0.19.0/tests/snapshots/diagnostics.svg +211 -0
  87. promptmend-0.19.0/tests/snapshots/history.svg +213 -0
  88. promptmend-0.19.0/tests/snapshots/home-70.svg +163 -0
  89. promptmend-0.19.0/tests/snapshots/home-80.svg +163 -0
  90. promptmend-0.19.0/tests/snapshots/home-high-contrast.svg +210 -0
  91. promptmend-0.19.0/tests/snapshots/home.svg +210 -0
  92. promptmend-0.19.0/tests/snapshots/intro-narrow.svg +150 -0
  93. promptmend-0.19.0/tests/snapshots/intro.svg +213 -0
  94. promptmend-0.19.0/tests/snapshots/previous-install.svg +207 -0
  95. promptmend-0.19.0/tests/snapshots/profiles.svg +207 -0
  96. promptmend-0.19.0/tests/snapshots/providers.svg +208 -0
  97. promptmend-0.19.0/tests/snapshots/triggers.svg +208 -0
  98. promptmend-0.19.0/tests/test_bench.py +1330 -0
  99. promptmend-0.19.0/tests/test_brew_formula.py +299 -0
  100. promptmend-0.19.0/tests/test_check_tool_lock.py +104 -0
  101. promptmend-0.19.0/tests/test_cli.py +1178 -0
  102. promptmend-0.19.0/tests/test_clipboard_guard.py +295 -0
  103. promptmend-0.19.0/tests/test_commands.py +1573 -0
  104. promptmend-0.19.0/tests/test_config.py +896 -0
  105. promptmend-0.19.0/tests/test_config_store.py +1052 -0
  106. promptmend-0.19.0/tests/test_deploy.py +1519 -0
  107. promptmend-0.19.0/tests/test_docs.py +228 -0
  108. promptmend-0.19.0/tests/test_doctor.py +451 -0
  109. promptmend-0.19.0/tests/test_factory.py +655 -0
  110. promptmend-0.19.0/tests/test_gate.py +189 -0
  111. promptmend-0.19.0/tests/test_history.py +1056 -0
  112. promptmend-0.19.0/tests/test_install_scripts.py +35 -0
  113. promptmend-0.19.0/tests/test_live.py +100 -0
  114. promptmend-0.19.0/tests/test_previous_install.py +1247 -0
  115. promptmend-0.19.0/tests/test_prompts.py +228 -0
  116. promptmend-0.19.0/tests/test_providers.py +831 -0
  117. promptmend-0.19.0/tests/test_recorder.py +445 -0
  118. promptmend-0.19.0/tests/test_redaction.py +812 -0
  119. promptmend-0.19.0/tests/test_release.py +196 -0
  120. promptmend-0.19.0/tests/test_relocate.py +535 -0
  121. promptmend-0.19.0/tests/test_strip_thinking.py +59 -0
  122. promptmend-0.19.0/tests/test_trigger_contract.py +408 -0
  123. promptmend-0.19.0/tests/test_triggers.py +278 -0
  124. promptmend-0.19.0/tests/test_tui.py +2039 -0
  125. promptmend-0.19.0/tests/test_tui_home.py +258 -0
  126. promptmend-0.19.0/tests/test_tui_snapshots.py +281 -0
  127. promptmend-0.19.0/tests/test_tui_teach.py +80 -0
  128. promptmend-0.19.0/tests/test_usage.py +514 -0
  129. promptmend-0.19.0/tests/test_user_profiles.py +460 -0
  130. promptmend-0.19.0/tests/test_version.py +11 -0
  131. promptmend-0.19.0/tests/test_yaml.py +291 -0
  132. promptmend-0.19.0/uv.lock +766 -0
@@ -0,0 +1,118 @@
1
+ # Copy to .env and fill in. The CLI loads this file without overriding real env.
2
+ # Never commit the real .env. It is gitignored. Keep it outside the repository: put it in
3
+ # ~/.config/promptmend/.env ($XDG_CONFIG_HOME/promptmend/.env when XDG_CONFIG_HOME is
4
+ # set; %APPDATA%\promptmend\.env on Windows), or set
5
+ # PROMPTMEND_ENV=/path/to/.env in the real environment (not in a .env file). The CLI also
6
+ # reads the .env in this repository (editable install), but an API key there sits in the
7
+ # working tree. Once config.toml exists in the config folder above, no .env is read (unless
8
+ # PROMPTMEND_ENV names one). Save it as UTF-8. An unquoted value ends at " #" (a
9
+ # comment): quote a value that contains " #", such as a regex or a persona.
10
+ #
11
+ # Only the key and the persona are set below. Every other line shows its default, commented
12
+ # out: uncomment a line only to change it, so later default changes still reach you.
13
+
14
+ # PROMPT_PROVIDER=openrouter
15
+ # PROMPT_PROFILE=default
16
+ # Your role, stated in the first person. Opens the CONTEXT section of the `default`
17
+ # profile's rewrite and the `-p-` snippet. Leave empty for no fixed persona.
18
+ PROMPT_PERSONA=
19
+ # PROMPT_PERSONA="I am working as a Head of Data at Example Corp."
20
+ # paste (the rewrite replaces the trigger) or clipboard (the trigger vanishes and the rewrite
21
+ # is on the clipboard; error markers still paste). improve --output overrides it per call.
22
+ # PROMPT_OUTPUT=paste
23
+ # PROMPT_TIMEOUT_SECONDS=30
24
+ # Timeout for the pro tier (-ip-), which runs a slower reasoning model.
25
+ # PROMPT_PRO_TIMEOUT_SECONDS=60
26
+ # Profile for the pro tier (-ip-, and -if- when it runs OPENROUTER_PRO_MODEL); empty uses
27
+ # PROMPT_PROFILE, so both tiers send the same prompt.
28
+ # PROMPT_PRO_PROFILE=
29
+ # Your own profiles are <name>.md files in ~/.config/promptmend/profiles/
30
+ # (%APPDATA%\promptmend\profiles\ on Windows). One named like a built-in (default,
31
+ # general) is ignored unless that built-in is listed here, comma-separated.
32
+ # PROMPT_PROFILE_OVERRIDES=
33
+ # Empty (PROMPT_TEMPERATURE=) sends no temperature, for models that reject the field;
34
+ # leaving it unset keeps 0.2.
35
+ # PROMPT_TEMPERATURE=0.2
36
+
37
+ # Local providers are ungated only on localhost. Pointed at another machine, or with an
38
+ # Ollama cloud model (a :cloud or -cloud tag, any case), the draft leaves this one, so the
39
+ # data-protection gate applies as it does to the cloud providers. Unlike the cloud providers
40
+ # they accept http:// for another machine, which sends the draft in clear text over the
41
+ # network: use https:// (or an SSH tunnel to localhost) there.
42
+
43
+ # Ollama
44
+ # OLLAMA_BASE_URL=http://localhost:11434
45
+ # OLLAMA_MODEL=qwen3:8b
46
+ # OLLAMA_THINK=false
47
+
48
+ # LM Studio (OpenAI-compatible, local, no key needed)
49
+ # LMSTUDIO_BASE_URL=http://localhost:1234/v1
50
+ # LMSTUDIO_MODEL=local-model
51
+
52
+ # OpenRouter (cloud, default provider). Leave key empty if company policy
53
+ # does not permit sending drafts off-machine; the data-protection gate blocks
54
+ # sensitive content regardless. For local-only use set PROMPT_LOCAL_ONLY=true below:
55
+ # the cloud triggers name their provider, so PROMPT_PROVIDER does not change them.
56
+ OPENROUTER_API_KEY=
57
+ # OPENROUTER_MODEL=google/gemini-3.5-flash-lite
58
+ # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
59
+ # OPENROUTER_MAX_TOKENS=2400
60
+ # Pin one serving endpoint. OpenRouter routes a model across many hosts that differ in
61
+ # latency, price and output quality; see the benchmark section in README.md. List the tags
62
+ # with GET https://openrouter.ai/api/v1/models/<author>/<slug>/endpoints. Leave empty for
63
+ # OpenRouter's own blended routing.
64
+ # OPENROUTER_PROVIDER=google-ai-studio/flex
65
+ # true keeps the pin a preference, so a dead endpoint falls back instead of erroring.
66
+ # false pins hard: predictable cost and latency, but outages become visible failures.
67
+ # OPENROUTER_ALLOW_FALLBACKS=true
68
+ # Data policy for every OpenRouter call (both tiers): deny routes only to endpoints that
69
+ # do not store or train on requests (fewer endpoints; a pin that does not qualify falls
70
+ # back, or fails with ALLOW_FALLBACKS=false), allow permits them. Empty sends nothing, so
71
+ # your OpenRouter account setting applies.
72
+ # OPENROUTER_DATA_COLLECTION=
73
+ # Reasoning effort (none/minimal/low/medium/high). Leave empty for models without a
74
+ # reasoning control. Gemini 3.x cannot turn thinking off; minimal keeps it fast.
75
+ # OPENROUTER_REASONING_EFFORT=minimal
76
+ # Pro tier (-ip- / --tier pro): a reasoning model for hard, multi-part drafts.
77
+ # OPENROUTER_PRO_MODEL=openai/gpt-6-luna
78
+ # OPENROUTER_PRO_PROVIDER=openai
79
+ # OPENROUTER_PRO_REASONING_EFFORT=low
80
+ # Output cap for the pro tier only (its reasoning counts against it). Empty uses
81
+ # OPENROUTER_MAX_TOKENS for both tiers.
82
+ # OPENROUTER_PRO_MAX_TOKENS=
83
+
84
+ # Anthropic / Claude (cloud). Leave key empty unless policy permits cloud use.
85
+ # ANTHROPIC_API_KEY=
86
+ # ANTHROPIC_MODEL=claude-sonnet-5
87
+ # ANTHROPIC_BASE_URL=https://api.anthropic.com
88
+ # ANTHROPIC_MAX_TOKENS=2400
89
+
90
+ # Set true only to allow cloud calls on every flagged draft, permanently. To send one draft
91
+ # whose findings are only labels, IDs, emails or IBANs, use the -iok- trigger instead.
92
+ # ALLOW_CLOUD_OVERRIDE=false
93
+ # Set true to refuse every provider that can send the draft off this machine (-i-, -ip-, -if-,
94
+ # -iok-, -ic-, and local providers pointed elsewhere); -il- and -ilm- on localhost keep working.
95
+ # PROMPT_LOCAL_ONLY=false
96
+ # Set true when your localhost Ollama / LM Studio is a relay to a cloud API (LiteLLM, an SSH
97
+ # tunnel), or when OLLAMA_MODEL is a local alias of a cloud model (made with ollama cp, so its
98
+ # name has no cloud tag and counts as local): -il- and -ilm- then
99
+ # run the gate too. PROMPT_LOCAL_ONLY still allows them.
100
+ # PROMPT_GATE_LOCAL=false
101
+ # Your own ;-separated, case-insensitive regexes the gate also blocks on, e.g. project code
102
+ # names or customer-ID formats. Write a literal ; as \x3b. An invalid regex is rejected when
103
+ # settings load. Each pattern runs on every draft and on every value written to the usage
104
+ # history: avoid nested quantifiers such as (\w+\s?)+, which can take exponential time and
105
+ # stall the trigger; use a literal, a fixed count (\d{6}) or a bounded repeat (\w{1,20}).
106
+ # PROMPT_EXTRA_PATTERNS=
107
+ # PROMPT_EXTRA_PATTERNS="project[- ]falcon;CUST-\d{6}"
108
+
109
+ # Local usage history: metadata only (trigger, model, tokens, reported cost, latency), never
110
+ # your draft, rewrite, persona or keys; kept on this device in ~/.local/share/promptmend
111
+ # (%LOCALAPPDATA%\promptmend on Windows). false keeps no history.
112
+ # PROMPT_HISTORY=true
113
+ # Days a history record is kept (1 to 36500); each recorded run prunes older ones.
114
+ # PROMPT_HISTORY_RETENTION_DAYS=365
115
+
116
+ # The full-screen interface (`promptmend ui`) opens with a short intro; false skips it
117
+ # (as `promptmend ui --no-intro` does). The triggers never read this.
118
+ # PROMPT_UI_INTRO=true
@@ -0,0 +1,3 @@
1
+ # LF everywhere, on every OS: a CRLF install_macos.sh fails in bash, and the match files
2
+ # and prompts are deployed byte for byte. PowerShell 5.1 and Espanso both read LF.
3
+ * text=auto eol=lf
@@ -0,0 +1,51 @@
1
+ name: Bug report
2
+ description: Something does not work as documented
3
+ labels: [bug]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Please do not paste API keys, customer data or confidential drafts. Replace them with fake
9
+ values. Security problems belong in a private report (see SECURITY.md).
10
+ - type: textarea
11
+ id: what-happened
12
+ attributes:
13
+ label: What happened
14
+ description: "Include the exact `[promptmend: …]` message if you got one."
15
+ validations:
16
+ required: true
17
+ - type: textarea
18
+ id: expected
19
+ attributes:
20
+ label: What you expected
21
+ validations:
22
+ required: true
23
+ - type: textarea
24
+ id: reproduce
25
+ attributes:
26
+ label: Steps to reproduce
27
+ description: Trigger or CLI command, provider, profile, and a fake draft that shows the problem.
28
+ placeholder: |
29
+ 1. echo "…" | promptmend improve --provider openrouter --source stdin
30
+ 2. …
31
+ validations:
32
+ required: true
33
+ - type: dropdown
34
+ id: os
35
+ attributes:
36
+ label: Operating system
37
+ options: [macOS, Windows, Linux]
38
+ validations:
39
+ required: true
40
+ - type: dropdown
41
+ id: provider
42
+ attributes:
43
+ label: Provider
44
+ options: [openrouter, anthropic, ollama, lmstudio, not provider related]
45
+ validations:
46
+ required: true
47
+ - type: input
48
+ id: versions
49
+ attributes:
50
+ label: Versions
51
+ description: "Output of `uv tool list | grep promptmend` and `espanso --version`, plus the model if relevant."
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Report a security vulnerability
4
+ url: https://github.com/vlastimilbures/promptmend/security/advisories/new
5
+ about: Gate bypasses, key exposure and other vulnerabilities. Please report privately.
@@ -0,0 +1,21 @@
1
+ name: Feature request
2
+ description: Suggest a trigger, profile, provider or improvement
3
+ labels: [enhancement]
4
+ body:
5
+ - type: textarea
6
+ id: problem
7
+ attributes:
8
+ label: Problem
9
+ description: What are you trying to do, and what gets in the way today?
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: proposal
14
+ attributes:
15
+ label: Proposed solution
16
+ validations:
17
+ required: true
18
+ - type: textarea
19
+ id: alternatives
20
+ attributes:
21
+ label: Alternatives considered
@@ -0,0 +1,17 @@
1
+ ## What and why
2
+
3
+ <!-- What does this change, and what problem does it solve? Link the issue if there is one. -->
4
+
5
+ ## How it was tested
6
+
7
+ <!-- Commands you ran. For prompt or provider changes, include `pytest -m live` and benchmark
8
+ before/after pass rates. -->
9
+
10
+ ## Checklist
11
+
12
+ - [ ] `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .` and `uv run mypy` pass
13
+ - [ ] Tests added or updated for the behaviour change
14
+ - [ ] Cloud providers are built only through `factory.make_provider()` (gate intact)
15
+ - [ ] CLI output keeps the Espanso contract: no trailing newline, errors as `[promptmend: …]`
16
+ - [ ] CHANGELOG.md updated under *Unreleased* if users would notice
17
+ - [ ] No real keys, customer data or confidential text anywhere in the diff
@@ -0,0 +1,23 @@
1
+ # Repository instructions
2
+
3
+ `CLAUDE.md` at the repository root is the source of truth for this repository's architecture,
4
+ commands and rules; read it before changing code. `CONTRIBUTING.md` has the checks and ground
5
+ rules. The invariants below must never be broken:
6
+
7
+ - The CLI prints only through `cli._emit()` (no trailing newline); every failure is a
8
+ `[promptmend: ...]` marker on stdout with exit code 0, never a traceback.
9
+ - Build every provider with `factory.make_provider()`, which wraps any call that can leave the
10
+ machine in the data-protection gate. The only overrides are `ALLOW_CLOUD_OVERRIDE=true` and
11
+ `--allow-flagged` (one call, soft findings only); never add a code path around the gate.
12
+ - Every Espanso match that runs the CLI sets `force_mode: clipboard`, and every match sets
13
+ `left_word: true`.
14
+ - Match commands start with the quoted `"__PROMPT_WORKFLOW__"` placeholder and quote nothing
15
+ else; nothing is ever deployed to Espanso's `config/` folder.
16
+ - Keep existing triggers working and every feature cross-platform (macOS and Windows); a
17
+ renamed or removed trigger needs a migration note and a test.
18
+ - Never put API keys, customer data, credentials or confidential prompts in source, tests,
19
+ logs or commits.
20
+ - Tests stay offline: never call external APIs from unit tests; mock the HTTP client.
21
+ - Update README.md and the CHANGELOG `## Unreleased` section with every user-visible change.
22
+ - Do not run the paid bench (`scripts/bench_models.py`), `pytest -m live`, the installers or
23
+ `espanso` commands unless asked.
@@ -0,0 +1,32 @@
1
+ # Actions and pre-commit hooks are pinned to commit SHAs; Dependabot keeps those pins and
2
+ # uv.lock current. The cooldown skips releases younger than a week, the window in which a
3
+ # hijacked package is usually caught and yanked; security updates are not delayed by it.
4
+ version: 2
5
+ updates:
6
+ - package-ecosystem: github-actions
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ cooldown:
11
+ default-days: 7
12
+ groups:
13
+ actions:
14
+ patterns: ["*"]
15
+ - package-ecosystem: uv
16
+ directory: /
17
+ schedule:
18
+ interval: weekly
19
+ cooldown:
20
+ default-days: 7
21
+ groups:
22
+ python:
23
+ patterns: ["*"]
24
+ - package-ecosystem: pre-commit
25
+ directory: /
26
+ schedule:
27
+ interval: weekly
28
+ cooldown:
29
+ default-days: 7
30
+ groups:
31
+ pre-commit:
32
+ patterns: ["*"]
@@ -0,0 +1,287 @@
1
+ name: release
2
+ # Builds the sdist, the wheel and constraints.txt (the runtime pins from uv.lock), installs the
3
+ # wheel with those pins into a clean venv on every OS, then attests the files and creates the
4
+ # annotated tag v<version> and a GitHub Release with that version's CHANGELOG notes. While the
5
+ # repository variable PYPI_PUBLISH is "true", the same sdist and wheel then go to PyPI through
6
+ # trusted publishing (the pypi job, in the GitHub environment "pypi").
7
+ #
8
+ # A release happens only for the current head of main, when its pyproject version has no
9
+ # published Release yet:
10
+ # - manual dispatch with dry-run unticked (dry-run is the default: it builds and tests, nothing more);
11
+ # - a push to main, only while the repository variable RELEASE_ON_PUSH is "true", so merging
12
+ # a branch never releases by surprise.
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ workflow_dispatch:
17
+ inputs:
18
+ dry-run:
19
+ description: "Build and test the artifacts only: no attestation, tag or Release"
20
+ type: boolean
21
+ default: true
22
+ permissions: {}
23
+ jobs:
24
+ plan:
25
+ runs-on: ubuntu-latest
26
+ timeout-minutes: 5
27
+ permissions:
28
+ contents: read
29
+ outputs:
30
+ version: ${{ steps.plan.outputs.version }}
31
+ build: ${{ steps.plan.outputs.build }}
32
+ release: ${{ steps.plan.outputs.release }}
33
+ steps:
34
+ - name: Checkout
35
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
36
+ with:
37
+ persist-credentials: false
38
+ - name: Decide whether to release
39
+ id: plan
40
+ env:
41
+ GH_TOKEN: ${{ github.token }}
42
+ EVENT: ${{ github.event_name }}
43
+ DRY_RUN: ${{ inputs.dry-run }}
44
+ RELEASE_ON_PUSH: ${{ vars.RELEASE_ON_PUSH }}
45
+ run: |
46
+ # Fails unless the newest CHANGELOG section is the pyproject version, dated.
47
+ version=$(python3 scripts/release_notes.py version)
48
+ tag="v$version"
49
+ # A draft is invisible to this read-only token; the release job picks it up again.
50
+ if published=$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json isDraft \
51
+ --jq '.isDraft | not' 2> "$RUNNER_TEMP/err"); then :
52
+ elif grep -q "release not found" "$RUNNER_TEMP/err"; then published=false
53
+ else cat "$RUNNER_TEMP/err" >&2; exit 1
54
+ fi
55
+ head=$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)
56
+ build=false
57
+ release=false
58
+ if [ "$EVENT" = workflow_dispatch ]; then build=true; fi
59
+ if [ "$published" = true ]; then
60
+ echo "::notice::$tag is already released; nothing to release."
61
+ elif [ "$GITHUB_REF" != refs/heads/main ] || [ "$GITHUB_SHA" != "$head" ]; then
62
+ echo "::notice::$tag is released only from the head of main ($head), not $GITHUB_REF at $GITHUB_SHA."
63
+ elif [ "$EVENT" = workflow_dispatch ] && [ "$DRY_RUN" = true ]; then
64
+ echo "::notice::Dry run: $tag is built and tested, not released."
65
+ elif [ "$EVENT" = workflow_dispatch ]; then
66
+ release=true
67
+ elif [ "$EVENT" = push ] && [ "$RELEASE_ON_PUSH" = true ]; then
68
+ build=true
69
+ release=true
70
+ else
71
+ echo "::notice::$tag is not released yet: run the release workflow with dry-run unticked."
72
+ fi
73
+ {
74
+ echo "version=$version"
75
+ echo "build=$build"
76
+ echo "release=$release"
77
+ } >> "$GITHUB_OUTPUT"
78
+
79
+ build:
80
+ needs: plan
81
+ if: needs.plan.outputs.build == 'true'
82
+ runs-on: ubuntu-latest
83
+ timeout-minutes: 10
84
+ permissions:
85
+ contents: read
86
+ steps:
87
+ - name: Checkout
88
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
89
+ with:
90
+ persist-credentials: false
91
+ # No cache in a workflow that publishes: a poisoned cache could reach the artifacts.
92
+ - name: Set up uv
93
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
94
+ with:
95
+ python-version: "3.12"
96
+ enable-cache: false
97
+ - name: Build sdist, wheel and constraints
98
+ run: |
99
+ uv build --out-dir dist
100
+ uv export --frozen --no-dev --no-emit-project --no-hashes --output-file dist/constraints.txt
101
+ - name: Upload the artifacts
102
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
103
+ with:
104
+ name: dist
105
+ path: dist/
106
+ if-no-files-found: error
107
+
108
+ # The wheel goes into a clean venv outside the checkout, pinned by the constraints.txt being
109
+ # released, so the check covers what a user installs (see the wheel job in test.yml).
110
+ test-artifacts:
111
+ needs: [plan, build]
112
+ timeout-minutes: 10
113
+ permissions:
114
+ contents: read
115
+ strategy:
116
+ fail-fast: true
117
+ matrix:
118
+ os: [ubuntu-latest, windows-latest, macos-latest]
119
+ python-version: ["3.12", "3.14"]
120
+ runs-on: ${{ matrix.os }}
121
+ defaults:
122
+ run:
123
+ shell: pwsh
124
+ steps:
125
+ - name: Checkout
126
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
127
+ with:
128
+ persist-credentials: false
129
+ - name: Set up uv
130
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
131
+ with:
132
+ python-version: ${{ matrix.python-version }}
133
+ enable-cache: false
134
+ - name: Download the artifacts
135
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
136
+ with:
137
+ name: dist
138
+ path: ${{ runner.temp }}/dist
139
+ - name: Install the wheel with the constraints into a clean venv and check it
140
+ working-directory: ${{ runner.temp }}
141
+ env:
142
+ VERSION: ${{ needs.plan.outputs.version }}
143
+ PYTHON_VERSION: ${{ matrix.python-version }}
144
+ run: |
145
+ $PSNativeCommandUseErrorActionPreference = $true
146
+ $dist = Join-Path $env:RUNNER_TEMP "dist"
147
+ $wheel = (Get-ChildItem "$dist/*.whl" | Select-Object -First 1).FullName
148
+ $constraints = Join-Path $dist "constraints.txt"
149
+ $venv = Join-Path $env:RUNNER_TEMP "wheel-venv"
150
+ uv venv --python $env:PYTHON_VERSION $venv
151
+ uv pip install --python $venv $wheel -c $constraints
152
+ $python = if ($IsWindows) { "$venv/Scripts/python.exe" } else { "$venv/bin/python" }
153
+ $cli = if ($IsWindows) { "$venv/Scripts/promptmend.exe" } else { "$venv/bin/promptmend" }
154
+ $installed = & $cli --version
155
+ if ($installed -ne $env:VERSION) { throw "installed $installed, expected $env:VERSION" }
156
+ & $python "$env:GITHUB_WORKSPACE/scripts/check_wheel.py" --repo $env:GITHUB_WORKSPACE --wheel $wheel --constraints $constraints
157
+
158
+ release:
159
+ needs: [plan, build, test-artifacts]
160
+ if: needs.plan.outputs.release == 'true'
161
+ runs-on: ubuntu-latest
162
+ timeout-minutes: 10
163
+ # One release at a time, never cancelled half way between the tag and the Release.
164
+ concurrency:
165
+ group: release
166
+ cancel-in-progress: false
167
+ # The only job that can write to the repository: the tag, the Release and the attestations.
168
+ permissions:
169
+ contents: write
170
+ id-token: write
171
+ attestations: write
172
+ steps:
173
+ - name: Checkout
174
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
175
+ with:
176
+ persist-credentials: false
177
+ - name: Download the artifacts
178
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
179
+ with:
180
+ name: dist
181
+ path: dist
182
+ - name: Attest build provenance
183
+ uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
184
+ with:
185
+ subject-path: dist/*
186
+ # The tag is made through the API, so no git credentials are stored in the checkout.
187
+ # The Release starts as a draft and is published once every file is attached: with
188
+ # immutable releases enabled in the repository, publishing is what locks it. A run that
189
+ # failed half way can be re-run: an annotated tag already on this commit and a draft
190
+ # Release are reused.
191
+ - name: Create the annotated tag and the Release
192
+ env:
193
+ GH_TOKEN: ${{ github.token }}
194
+ VERSION: ${{ needs.plan.outputs.version }}
195
+ run: |
196
+ tag="v$VERSION"
197
+ repo="$GITHUB_REPOSITORY"
198
+ head=$(gh api "repos/$repo/commits/main" --jq .sha)
199
+ if [ "$GITHUB_SHA" != "$head" ]; then
200
+ echo "::error::main moved on to $head while $GITHUB_SHA was tested; not releasing."
201
+ exit 1
202
+ fi
203
+ python3 scripts/release_notes.py notes > "$RUNNER_TEMP/notes.md"
204
+
205
+ ref=$(gh api "repos/$repo/git/matching-refs/tags/$tag" \
206
+ --jq "map(select(.ref == \"refs/tags/$tag\")) | .[0] // empty | .object.type + \" \" + .object.sha")
207
+ if [ -z "$ref" ]; then
208
+ tag_sha=$(gh api "repos/$repo/git/tags" -f tag="$tag" -f message="Release $VERSION" \
209
+ -f object="$GITHUB_SHA" -f type=commit --jq .sha)
210
+ gh api "repos/$repo/git/refs" -f ref="refs/tags/$tag" -f sha="$tag_sha" > /dev/null
211
+ else
212
+ read -r type sha <<< "$ref"
213
+ target=""
214
+ if [ "$type" = tag ]; then target=$(gh api "repos/$repo/git/tags/$sha" --jq .object.sha); fi
215
+ if [ "$target" != "$GITHUB_SHA" ]; then
216
+ echo "::error::$tag already exists as a $type ($sha), not an annotated tag of $GITHUB_SHA."
217
+ exit 1
218
+ fi
219
+ echo "::notice::Reusing the annotated tag $tag."
220
+ fi
221
+
222
+ if draft=$(gh release view "$tag" --repo "$repo" --json isDraft --jq .isDraft 2> "$RUNNER_TEMP/err"); then :
223
+ elif grep -q "release not found" "$RUNNER_TEMP/err"; then draft=none
224
+ else cat "$RUNNER_TEMP/err" >&2; exit 1
225
+ fi
226
+ case "$draft" in
227
+ none)
228
+ gh release create "$tag" --repo "$repo" --verify-tag --draft \
229
+ --title "$tag" --notes-file "$RUNNER_TEMP/notes.md" dist/* ;;
230
+ true)
231
+ echo "::notice::Reusing the draft Release $tag."
232
+ gh release edit "$tag" --repo "$repo" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md"
233
+ gh release upload "$tag" --repo "$repo" --clobber dist/* ;;
234
+ *)
235
+ echo "::error::$tag is already published."
236
+ exit 1 ;;
237
+ esac
238
+
239
+ # Latest only if no published Release has a higher version.
240
+ newest=$( { gh release list --repo "$repo" --exclude-drafts --limit 1000 \
241
+ --json tagName --jq '.[].tagName'; echo "$tag"; } | sort -V | tail -n 1)
242
+ latest=false
243
+ if [ "$newest" = "$tag" ]; then latest=true; fi
244
+ gh release edit "$tag" --repo "$repo" --draft=false --latest="$latest"
245
+
246
+ # PyPI, after the Release is published, with the very sdist and wheel the release job attested
247
+ # (the artifact of this run, never a rebuild). Off until the owner sets PYPI_PUBLISH to "true"
248
+ # and registers this workflow and the environment "pypi" as a trusted publisher on PyPI, so a
249
+ # release keeps working before that. It holds no repository permission, only the OIDC token
250
+ # PyPI exchanges for a short-lived upload token; no API token is stored anywhere. If it fails,
251
+ # "Re-run failed jobs" uploads the same files again (skip-existing: files PyPI already has
252
+ # are left alone). A fresh run for a published version stops at plan, so it never re-uploads.
253
+ pypi:
254
+ needs: [plan, release]
255
+ if: needs.plan.outputs.release == 'true' && vars.PYPI_PUBLISH == 'true'
256
+ runs-on: ubuntu-latest
257
+ timeout-minutes: 10
258
+ concurrency:
259
+ group: pypi
260
+ cancel-in-progress: false
261
+ environment:
262
+ name: pypi
263
+ url: https://pypi.org/project/promptmend/${{ needs.plan.outputs.version }}/
264
+ permissions:
265
+ id-token: write
266
+ steps:
267
+ - name: Download the artifacts
268
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
269
+ with:
270
+ name: dist
271
+ path: ${{ runner.temp }}/artifacts
272
+ # Exactly the sdist and the wheel of this version; constraints.txt stays on the Release.
273
+ - name: Pick the sdist and the wheel
274
+ env:
275
+ VERSION: ${{ needs.plan.outputs.version }}
276
+ run: |
277
+ mkdir "$RUNNER_TEMP/pypi"
278
+ for name in "promptmend-$VERSION.tar.gz" \
279
+ "promptmend-$VERSION-py3-none-any.whl"; do
280
+ cp "$RUNNER_TEMP/artifacts/$name" "$RUNNER_TEMP/pypi/"
281
+ done
282
+ ls -l "$RUNNER_TEMP/pypi"
283
+ - name: Publish to PyPI
284
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
285
+ with:
286
+ packages-dir: ${{ runner.temp }}/pypi
287
+ skip-existing: true
@@ -0,0 +1,25 @@
1
+ name: secret-scan
2
+ on:
3
+ # Every branch: a secret pushed anywhere is already public.
4
+ push:
5
+ pull_request:
6
+ permissions:
7
+ contents: read
8
+ # gitleaks-action lists a pull request's commits to scan only those.
9
+ pull-requests: read
10
+ concurrency:
11
+ group: secret-scan-${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+ jobs:
14
+ gitleaks:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 10
17
+ steps:
18
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
19
+ with:
20
+ fetch-depth: 0
21
+ persist-credentials: false
22
+ - name: Run gitleaks
23
+ uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
24
+ env:
25
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}