Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 0 additions & 31 deletions .github/workflows/release-please.yml

This file was deleted.

79 changes: 59 additions & 20 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,24 @@ via OIDC (no secrets needed).
| Python | [PyPI](https://pypi.org/project/prompty/) | `python/{version}` | `prompty-python.yml` |
| TypeScript | [npm](https://www.npmjs.com/package/@prompty/core) | `typescript/{version}` | `prompty-ts-release.yml` |

Those tags are now produced automatically by **release-please** (see below). The
manual tag-and-push steps later in this document remain as a break-glass path.
Those tags are produced with **release-please**, run **locally by a maintainer**
(see below). The manual tag-and-push steps later in this document remain as a
break-glass path.

## Automated releases (release-please)
## Automated releases (release-please, maintainer-run)

Prompty ships **seven runtimes independently** via
[release-please](https://github.com/googleapis/release-please). There is no shared
version number — `python/2.1.0` and `rust/2.0.3` can coexist. Config lives in
`release-please-config.json` + `.release-please-manifest.json` at the repo root, and
the driver is `.github/workflows/release-please.yml`.
`release-please-config.json` + `.release-please-manifest.json` at the repo root.

> **Why not a CI workflow?** The `microsoft` org blocks the Actions `GITHUB_TOKEN`
> from creating pull requests, and a tag cut by `GITHUB_TOKEN` does **not** trigger
> the tag-listening publish workflows. So there is no `release-please.yml` — a
> maintainer runs the release-please **CLI locally under their own `gh` identity**.
> The org permits real users to open PRs and push tags, so this needs **no GitHub
> App, no PAT, and no org-owner approval**. A tag pushed by a real user *does*
> trigger the publish workflow.

> **VS Code extension** (`vscode/`) is intentionally **not** managed by
> release-please — it uses the Marketplace pre-release channel (`vscode/2.0.0-pre.N`)
Expand All @@ -33,19 +41,23 @@ the driver is `.github/workflows/release-please.yml`.
conventional commit on main
│
▼
release-please.yml ── opens/updates one "Release PR" per runtime with changes
│ (title: "chore(<runtime>): release <ver>")
maintainer runs `release-please manifest-pr` ── opens/updates one "Release PR"
│ (locally, as themselves) per runtime with changes
▼ (title: "chore(<runtime>): release <ver>")
merge the Release PR ── bumps version files, writes CHANGELOG
│
▼
merge the Release PR ── bumps version files, writes CHANGELOG, pushes a tag
│ like `python/2.1.0`
maintainer runs `release-please manifest-release` ── pushes a tag like `python/2.1.0`
│ + creates the GitHub Release
▼
prompty-<runtime>.yml ── existing publish workflow triggers on the tag and publishes
```

release-please **only opens PRs and cuts tags**; nothing publishes until a human
merges a Release PR. Commits route to a runtime by the **paths they touch**; add a
scope when ambiguous (`feat(python):`, `fix(rust):`). Only `feat`, `fix`, `perf`,
`revert`, `deps` trigger releases; `feat!:` / `BREAKING CHANGE:` bumps major.
merges a Release PR *and* runs `manifest-release`. Commits route to a runtime by the
**paths they touch**; add a scope when ambiguous (`feat(python):`, `fix(rust):`).
Only `feat`, `fix`, `perf`, `revert`, `deps` trigger releases; `feat!:` /
`BREAKING CHANGE:` bumps major.

### Components

Expand All @@ -68,15 +80,42 @@ All runtimes were on `2.0.0-beta.N`. The manifest is seeded at each runtime's la
beta with **no prerelease mode**, so the first Release PR graduates each runtime to a
clean **`2.0.0`** stable release; normal `2.0.1` / `2.1.0` / `3.0.0` bumps follow.

### One-time setup before enabling on `main`
### Running a release (maintainer CLI)

Run from a clone where `gh auth status` shows **you** authenticated with `repo` +
`workflow` scopes. The `$(gh auth token)` below passes your identity to the CLI.

> **CRITICAL:** always pass `--config-file` **and** `--manifest-file`. Without them
> release-please ignores `release-please-config.json` and falls back to its default
> `-` tag separator, producing a bad tag like `python-2.0.0` (hyphen) that the
> publish workflow ignores. The config sets `separator:"/"` → `python/2.0.0`.

```bash
# 1. Open / update the Release PR (safe to re-run; add --dry-run to preview)
npx --yes release-please@16 manifest-pr \
--token="$(gh auth token)" --repo-url=microsoft/prompty --target-branch=main \
--config-file=release-please-config.json \
--manifest-file=.release-please-manifest.json

# 2. Merge the Release PR on GitHub (all publish checks must be green).

# 3. Cut the tag + GitHub Release, which triggers the publish workflow
npx --yes release-please@16 manifest-release \
--token="$(gh auth token)" --repo-url=microsoft/prompty --target-branch=main \
--config-file=release-please-config.json \
--manifest-file=.release-please-manifest.json
```

If `manifest-release` ever produces the wrong tag, remediate deterministically:

```bash
# delete the bad release + tag, recreate the correct one at the merge commit
gh release delete "python-2.0.0" --repo microsoft/prompty --yes --cleanup-tag
gh release create "python/2.0.0" --repo microsoft/prompty \
--target <merge-commit-sha> --title "python/2.0.0" --notes-file notes.md
```

1. **`RELEASE_PLEASE_TOKEN` secret (required for auto-publish).** The workflow falls
back to `GITHUB_TOKEN`, but tags cut with `GITHUB_TOKEN` **do not trigger** the
tag-listening publish workflows. Provide a **GitHub App installation token** (or
fine-grained PAT) with `contents:write` + `pull_requests:write` as
`RELEASE_PLEASE_TOKEN`.
2. **`bootstrap-sha`** in the config is pinned to the adoption commit so the first run
doesn't replay old history.
A tag pushed this way (by you) triggers `prompty-python.yml` → PyPI publish via OIDC.

### Verify on the first Release PR (before merging)

Expand Down
9 changes: 8 additions & 1 deletion runtime/python/prompty/prompty/parsers/prompty.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ def _build_message(
# Validate nonce in strict mode
if nonce is not None:
msg_nonce = attrs.pop("nonce", None)
# Compare as strings — _parse_attrs may coerce all-digit hex nonces to int
# _parse_attrs exempts nonce from coercion, so it is already a string;
# str() is a defensive no-op for any legacy/hand-built attr dict.
if str(msg_nonce) != nonce:
raise ValueError(
"Nonce mismatch — possible prompt injection detected "
Expand All @@ -205,6 +206,12 @@ def _parse_attrs(self, raw: str) -> dict[str, Any]:
for m in re.finditer(pattern, inner):
key = m.group(1)
val_str = m.group(2).strip()
# The nonce is a random hex token that must round-trip byte-for-byte;
# numeric coercion would drop a leading zero (e.g. "0123..." -> 123...)
# and make strict mode reject its own untampered output. Keep it a string.
if key == "nonce":
result[key] = val_str
continue
# Type coercion
if val_str.lower() in ("true", "false"):
result[key] = val_str.lower() == "true"
Expand Down
62 changes: 62 additions & 0 deletions runtime/python/prompty/tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,68 @@ def test_no_nonce_skips_validation(self):
assert result[0].role == "system"


# ---------------------------------------------------------------------------
# Nonce coercion regression (issue #520) — Python was the laggard; Java/Swift
# already fixed this. The strict-mode sanitization nonce is random hex from
# secrets.token_hex, so it is NOT vector-governed (vectors are deterministic) —
# it is pinned by these runtime-local tests. ~1 in 1,200 generated nonces is
# all-digits-with-leading-zero and used to be int-coerced, dropping the zero and
# making strict mode reject its own untampered output as a prompt injection.
# ---------------------------------------------------------------------------


class TestNonceCoercion:
# 16 hex chars (valid token_hex(8) outputs) crafted to hit each coercion mode.
NUMERIC_LOOKING_NONCES = [
"0123456789012345", # leading zero stripped by int()
"0419856025378190", # leading zero stripped by int()
"1234567890123456", # parses cleanly as int
"9789350921772800", # parses as int (large)
"0663512342083e99", # scientific notation -> float
"00000000000000e1", # leading zeros plus exponent
"45e12345678901ab", # trailing hex letters block numeric parsing
"abcdef0123456789", # leading hex letter blocks numeric parsing
]

def setup_method(self):
self.parser = PromptyChatParser()
self.agent = _make_agent()

def test_nonce_preserved_as_string_in_parse_attrs(self):
for nonce in self.NUMERIC_LOOKING_NONCES:
attrs = self.parser._parse_attrs(f'[nonce="{nonce}"]')
assert attrs["nonce"] == nonce
assert isinstance(attrs["nonce"], str)

def test_strict_parse_accepts_numeric_looking_nonces(self):
for nonce in self.NUMERIC_LOOKING_NONCES:
rendered = f'system[nonce="{nonce}"]:\nYou are helpful.\n\nuser[nonce="{nonce}"]:\nHello.'
messages = self.parser.parse(self.agent, rendered, nonce=nonce)
assert len(messages) == 2
assert messages[0].role == "system"
assert messages[1].role == "user"

def test_mismatched_numeric_nonce_still_rejected(self):
rendered = 'system[nonce="0123456789012345"]:\nHi'
with pytest.raises(ValueError, match="Nonce mismatch"):
self.parser.parse(self.agent, rendered, nonce="0123456789012346")

def test_non_nonce_attributes_still_coerced(self):
attrs = self.parser._parse_attrs('[nonce="0123456789012345",index=1,ratio=0.5,active=true,name="Alice"]')
assert attrs["nonce"] == "0123456789012345"
assert attrs["index"] == 1
assert attrs["ratio"] == 0.5
assert attrs["active"] is True
assert attrs["name"] == "Alice"

def test_generated_nonces_always_validate(self):
for _ in range(20_000):
sanitized, context = self.parser.pre_render("system:\nHi\n\nuser:\nHello")
nonce = context["nonce"]
attrs = self.parser._parse_attrs(f'[nonce="{nonce}"]')
assert attrs["nonce"] == nonce


# ---------------------------------------------------------------------------
# ReDoS regression (issue #446) — performance, not vector-expressible
# ---------------------------------------------------------------------------
Expand Down
Loading