diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index a3885b1ec..000000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: release-please - -# Opens/maintains one "Release PR" per runtime and, when a Release PR is merged, -# tags the runtime (e.g. python/2.0.0). Existing per-runtime publish workflows -# (prompty-python.yml, prompty-rust.yml, prompty-ts-release.yml, prompty-csharp.yml) -# trigger on those tags and do the actual package publishing. -# -# IMPORTANT: `token` must be a GitHub App installation token or a PAT — NOT the -# default GITHUB_TOKEN. Tags created with GITHUB_TOKEN do not trigger the -# tag-listening publish workflows (GitHub's recursive-workflow guard). Provide a -# token via the RELEASE_PLEASE_TOKEN secret so the publish workflows fire. - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4 - with: - token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} - config-file: release-please-config.json - manifest-file: .release-please-manifest.json diff --git a/RELEASING.md b/RELEASING.md index 6af2e73d1..2c7e6f9a3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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`) @@ -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(): release ") +maintainer runs `release-please manifest-pr` ── opens/updates one "Release PR" + │ (locally, as themselves) per runtime with changes + ▼ (title: "chore(): release ") +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-.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 @@ -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 --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) diff --git a/runtime/python/prompty/prompty/parsers/prompty.py b/runtime/python/prompty/prompty/parsers/prompty.py index 62cb353f3..1ed85e535 100644 --- a/runtime/python/prompty/prompty/parsers/prompty.py +++ b/runtime/python/prompty/prompty/parsers/prompty.py @@ -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 " @@ -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" diff --git a/runtime/python/prompty/tests/test_parsers.py b/runtime/python/prompty/tests/test_parsers.py index 9c242d66d..fd6fd3751 100644 --- a/runtime/python/prompty/tests/test_parsers.py +++ b/runtime/python/prompty/tests/test_parsers.py @@ -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 # ---------------------------------------------------------------------------