diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..cb1725c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,82 @@ +name: quality + +on: + push: + branches: + - main + - "*.x" + pull_request: + +jobs: + pint: + name: Code style + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check formatting + run: composer test:lint + + changelog: + name: Changelog parity + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # CHANGELOG.md and the documentation site's changelog page are written + # separately and drift silently. Every released version must appear in + # both, in the same order. + - name: Compare CHANGELOG.md against the documentation changelog + run: | + python3 - <<'PY' + import re, sys + from pathlib import Path + + repo = Path("CHANGELOG.md").read_text() + docs = Path("docs/changelog.mdx").read_text() + + # "## [0.4.5] - 2026-08-03" -> 0.4.5, ignoring [Unreleased]. + in_repo = re.findall(r"^## \[(\d[^\]]*)\]", repo, re.M) + + # '' -> 0.4.5 + in_docs = [v.lstrip("v") for v in re.findall(r' in docs/changelog.mdx") + + for missing in [v for v in in_docs if v not in in_repo]: + problems.append(f"{missing} has an in docs/changelog.mdx but no CHANGELOG.md entry") + + if not problems and in_repo != in_docs: + problems.append( + "both files list the same versions in a different order\n" + f" CHANGELOG.md : {' '.join(in_repo)}\n" + f" docs/changelog.mdx : {' '.join(in_docs)}" + ) + + if problems: + print("Changelog parity check failed:\n") + for p in problems: + print(f" - {p}") + print("\nUpdate both files so each release appears in each, newest first.") + sys.exit(1) + + print(f"OK - {len(in_repo)} releases match across both changelogs.") + print(f" {' '.join(in_repo)}") + PY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f123aa..821ceff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,4 +44,4 @@ jobs: --with="orchestra/testbench=${{ matrix.testbench }}" - name: Execute tests - run: vendor/bin/pest + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md index e802988..9a1182c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [0.4.5] - 2026-08-03 + +### Added + +- Added a `quality` workflow running the Pint formatting check on every push and pull request, and asserting that every release in `CHANGELOG.md` has a matching entry in the documentation site's changelog. Formatting was previously never checked in CI, and the two changelogs could drift silently. + +### Changed + +- `Deck::activate()` now accepts `string|int` versions, so `'v2'`, `'2'`, and `2` are all valid. Only `Deck::get()` was widened in `0.4.2`, despite the changelog describing both. +- The `tests` workflow now runs `composer test` rather than calling `vendor/bin/pest` directly, so CI and the documented contributor command cannot diverge. + +### Fixed + +- Fixed an unparseable version producing a message with an empty version number, such as `Version for prompt [order-summary] does not exist.` Both `Deck::get()` and `Deck::activate()` now throw `InvalidVersionException` naming the offending value. +- Fixed the README prompt structure diagram, which showed a `user.md` that `make:prompt` does not create without `--user`, a second version that a single run does not create, and omitted the version-level `metadata.json` added in `0.4.4`. +- Fixed the prompt structure diagrams on the introduction, configuration, prompts, and make:prompt documentation pages, which all omitted the version-level `metadata.json`. +- Fixed the README describing new versions as activating automatically. Creating a version has not changed the active version since `0.4.4`. +- Fixed the documented `Deck::get()` signature, which described `?int` rather than `string|int|null`. + +### Removed + ## [0.4.4] - 2026-08-03 ### Fixed diff --git a/README.md b/README.md index ce64a32..2310077 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,28 @@ php artisan make:prompt order-summary This creates the following structure +```txt +resources/prompts/order-summary/ +├── metadata.json # Prompt-level: name, description, roles, active version +└── v1/ + ├── metadata.json # This version's own metadata + └── system.md +``` + +Pass `--user` to add a user prompt, or `--role=` for any other role + +```bash +php artisan make:prompt order-summary --user --role=assistant +``` + ```txt resources/prompts/order-summary/ ├── metadata.json -├── v1/ -│ ├── system.md -│ └── user.md -└── v2/ +└── v1/ + ├── metadata.json ├── system.md - └── user.md + ├── user.md + └── assistant.md ``` Edit `resources/prompts/order-summary/v1/system.md` with your prompt content. Use `{{ $variable }}` syntax for dynamic values: @@ -87,14 +100,46 @@ $messages = $prompt->toMessages(['tone' => 'friendly', 'order' => $orderDetails] ### Versioning -Create a new version of an existing prompt +Run the command again on an existing prompt and Deck offers to create the next version or overwrite the current one ```bash php artisan make:prompt order-summary -# Automatically creates v2, v3, etc. + +# Prompt [order-summary] already exists at version 1. +# What would you like to do? +# [version ] Create a new version (v2) +# [overwrite] Overwrite version 1 ``` -Activate a specific version +Creating a version never changes which one your application serves, so you can draft freely in production + +```txt +resources/prompts/order-summary/ +├── metadata.json # "active_version": 1 +├── v1/ # live: what Deck::get('order-summary') returns +│ ├── metadata.json +│ └── system.md +└── v2/ # drafted, not serving traffic yet + ├── metadata.json + └── system.md +``` + +Check which version is live at any time + +```bash +php artisan prompt:list --all +``` + +```txt ++---------------+----------------+--------+-------------+ +| Prompt | Active Version | Active | Description | ++---------------+----------------+--------+-------------+ +| order-summary | v1 | ✅ | | +| order-summary | v2 | | | ++---------------+----------------+--------+-------------+ +``` + +Promote the new version when you are ready ```bash php artisan prompt:activate order-summary v2 @@ -104,10 +149,17 @@ php artisan prompt:activate order-summary v2 php artisan prompt:activate order-summary 2 ``` -Or load a specific version programmatically +The `active_version` key in the prompt's root `metadata.json` flips to `2`, and every `Deck::get('order-summary')` call starts returning v2. Roll back by activating v1 again. + +Both formats work programmatically too ```php +// Load a specific version. $prompt = Deck::get('order-summary', 'v2'); + +// Promote a version. +Deck::activate('order-summary', 'v2'); +Deck::activate('order-summary', 2); ``` ### Laravel AI SDK Integration diff --git a/docs/advanced/api-reference.mdx b/docs/advanced/api-reference.mdx index 9ffa845..82dc74f 100644 --- a/docs/advanced/api-reference.mdx +++ b/docs/advanced/api-reference.mdx @@ -64,15 +64,18 @@ $versions = $manager->versions('order-summary'); // ] ``` -#### `activate(string $name, int $version): bool` +#### `activate(string $name, string|int $version): bool` Activate a specific version of a prompt. Returns `true` on success. +Accepts the same version formats as `get()`, so `2`, `'2'`, and `'v2'` are equivalent. A version that cannot be parsed throws `InvalidVersionException`. + - **With tracking enabled:** Updates the `prompt_versions` database table. - **Without tracking:** Writes to `metadata.json` in the prompt directory. ```php $manager->activate('order-summary', 2); +$manager->activate('order-summary', 'v2'); ``` #### `track(string $promptName, int $version, array $data): void` @@ -222,10 +225,10 @@ Static proxy to the `PromptManager` singleton. | Method | Returns | Description | | ------------------------------------------------------------ | ---------------- | ------------------------------------------- | -| `Deck::get(string $name, ?int $version = null)` | `PromptTemplate` | Load a prompt by name and optional version. | +| `Deck::get(string $name, string\|int\|null $version = null)` | `PromptTemplate` | Load a prompt by name and optional version. | | `Deck::active(string $name)` | `PromptTemplate` | Load the active version of a prompt. | | `Deck::versions(string $name)` | `array` | List all versions for a prompt. | -| `Deck::activate(string $name, int $version)` | `bool` | Activate a specific version. | +| `Deck::activate(string $name, string\|int $version)` | `bool` | Activate a specific version. | | `Deck::track(string $name, int $version, array $data)` | `void` | Record a prompt execution. | --- diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 3212da8..acae31a 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -6,6 +6,31 @@ rss: true Deck follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Subscribe to the [RSS feed](https://deck.promptphp.com/changelog/rss.xml) to be notified of new releases, or browse the [full history on GitHub](https://github.com/promptphp/deck/blob/0.x/CHANGELOG.md). + + +**Added** + +- A `quality` CI workflow now checks formatting with Pint on every push and pull request, and asserts that every release in the repository `CHANGELOG.md` has a matching entry on this page. Formatting was never checked in CI before, and the two changelogs could drift apart unnoticed. +- The test workflow now runs `composer test` rather than calling Pest directly, so CI and the documented contributor command cannot diverge. + +**Changed** + +- **`Deck::activate()` now accepts string versions.** `'v2'`, `'2'`, and `2` are all valid, matching what `Deck::get()` has accepted since `v0.4.2`. Only `get()` was widened at the time, so `Deck::activate('order-summary', 'v2')` raised a `TypeError` despite the release notes describing both. Passing an integer continues to work unchanged. + + ```php + Deck::activate('order-summary', 'v2'); + Deck::activate('order-summary', 2); + ``` + +**Fixed** + +- **Unparseable versions now name the offending value.** Both `Deck::get()` and `Deck::activate()` threw a message with the version missing entirely — `Version for prompt [order-summary] does not exist.` They now throw `InvalidVersionException` reading `Invalid version [banana] for prompt [order-summary]. Use a positive number like [1] or [v1].` +- Corrected the prompt structure diagrams throughout the documentation and README. They omitted the version-level `metadata.json` introduced in `v0.4.4`, and the README showed a `user.md` that `make:prompt` does not create without `--user` alongside a second version that a single run does not create. +- Corrected the README describing new versions as becoming active automatically. Creating a version has not changed the active version since `v0.4.4`. +- Corrected the documented `Deck::get()` signature, which read `?int` rather than `string|int|null`. + + + **Fixed** diff --git a/docs/core/make-prompt.mdx b/docs/core/make-prompt.mdx index f5bc78c..57c9e20 100644 --- a/docs/core/make-prompt.mdx +++ b/docs/core/make-prompt.mdx @@ -22,12 +22,13 @@ This generates the following structure inside your configured prompts directory ``` resources/prompts/ └── order-summary/ - ├── v1/ - │ └── system.md - └── metadata.json + ├── metadata.json # Prompt-level: name, description, roles, active version + └── v1/ + ├── metadata.json # This version's own metadata + └── system.md ``` -A **system prompt** file is always created. A `metadata.json` file is placed at the prompt root to record the prompt's name, description, roles, and creation timestamp. +A **system prompt** file is always created. Two `metadata.json` files are written: one at the prompt root recording the prompt's name, description, roles, and creation timestamp, and one inside the version directory recording that version alone. ### Interactive mode diff --git a/docs/core/prompts.mdx b/docs/core/prompts.mdx index 50daf51..c0971fa 100644 --- a/docs/core/prompts.mdx +++ b/docs/core/prompts.mdx @@ -290,16 +290,20 @@ Prompts are versioned using directory-based versioning. Each version lives in it ``` resources/prompts/ └── order-summary/ - ├── v1/ + ├── metadata.json # "active_version": 1 + ├── v1/ # live + │ ├── metadata.json │ ├── system.md │ └── user.md - ├── v2/ - │ ├── system.md - │ ├── user.md - │ └── assistant.md - └── metadata.json + └── v2/ # drafted, not serving traffic yet + ├── metadata.json + ├── system.md + ├── user.md + └── assistant.md ``` +Only the active version is served. Creating a new version leaves `active_version` untouched, so you can draft in production and promote with [`prompt:activate`](/core/commands#promptactivate) when ready. + Each version directory can contain: - Any number of role files (e.g. `system.md`, `user.md`, `assistant.md`) @@ -334,8 +338,11 @@ Set a specific version as the active version: ```php Deck::activate('order-summary', 2); +Deck::activate('order-summary', 'v2'); ``` +The version accepts the same formats as `Deck::get()` — `2`, `'2'`, and `'v2'` are equivalent. Anything that cannot be parsed throws `InvalidVersionException`. + **When database tracking is enabled**, this updates the `prompt_versions` table — setting `is_active = false` on all versions of that prompt, then `is_active = true` on the specified version. **When tracking is disabled**, it writes the `active_version` key to the prompt's root `metadata.json` file. diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index ea217b6..65118ac 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -67,12 +67,14 @@ Currently, only the `directory` strategy is supported. Each version is stored in ``` resources/prompts/order-summary/ -├── v1/ +├── metadata.json # "active_version": 1 +├── v1/ # live +│ ├── metadata.json │ └── system.md -├── v2/ -│ ├── system.md -│ └── user.md -└── metadata.json +└── v2/ # drafted, not serving traffic yet + ├── metadata.json + ├── system.md + └── user.md ``` ## Cache diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index cc6eb0a..b79cb38 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -33,9 +33,10 @@ This creates the following structure: ``` resources/prompts/ └── order-summary/ - ├── v1/ - │ └── system.md - └── metadata.json + ├── metadata.json # Prompt-level: name, description, roles, active version + └── v1/ + ├── metadata.json # This version's own metadata + └── system.md ``` Edit `resources/prompts/order-summary/v1/system.md` with your prompt content. Use `{{ $variable }}` syntax for dynamic values: diff --git a/src/Exceptions/InvalidVersionException.php b/src/Exceptions/InvalidVersionException.php index a4b5014..2fe5ee8 100644 --- a/src/Exceptions/InvalidVersionException.php +++ b/src/Exceptions/InvalidVersionException.php @@ -24,4 +24,17 @@ public static function noVersions(string $name): self { return new self("No versions found for prompt [{$name}]."); } + + /** + * Create an exception for a version that could not be parsed. + * + * @param string $name The name of the prompt. + * @param string|int $version The version input that could not be parsed. + */ + public static function unparseable(string $name, string|int $version): self + { + return new self( + "Invalid version [{$version}] for prompt [{$name}]. Use a positive number like [1] or [v1]." + ); + } } diff --git a/src/Facades/Deck.php b/src/Facades/Deck.php index 019f576..0192320 100644 --- a/src/Facades/Deck.php +++ b/src/Facades/Deck.php @@ -11,7 +11,7 @@ * @method static \PromptPHP\Deck\PromptTemplate get(string $name, string|int|null $version = null) * @method static \PromptPHP\Deck\PromptTemplate active(string $name) * @method static array versions(string $name) - * @method static bool activate(string $name, int $version) + * @method static bool activate(string $name, string|int $version) * @method static void track(string $promptName, int $version, array $data) * * @see PromptManager diff --git a/src/PromptManager.php b/src/PromptManager.php index 13bf2df..3a30524 100644 --- a/src/PromptManager.php +++ b/src/PromptManager.php @@ -53,8 +53,8 @@ public function get(string $name, string|int|null $version = null): PromptTempla if ($version === null) { $version = $this->getActiveVersion($name); } else { - $versionInput = (string) $version; - $version = $this->parseVersion($versionInput); + $version = $this->parseVersion((string) $version) + ?? throw InvalidVersionException::unparseable($name, $version); } $cacheKey = $this->config->get('deck.cache.prefix', 'deck:')."{$name}.v{$version}"; @@ -132,11 +132,23 @@ public function versions(string $name): array /** * Activate a specific version. * - * For simplicity, we'll store in a JSON file or use the database if tracking is enabled. - * We'll assume we have a "prompt_versions" table with an "is_active" column. + * Accepts the same version formats as get(), so both of these work: + * + * Deck::activate('order-summary', 'v2') + * Deck::activate('order-summary', 2) + * + * The active version is recorded in the database when tracking is enabled, + * and in the prompt's root metadata.json either way. + * + * @param string|int $version The version to activate, e.g. 2 or 'v2'. + * + * @throws InvalidVersionException if the version cannot be parsed. */ - public function activate(string $name, int $version): bool + public function activate(string $name, string|int $version): bool { + $version = $this->parseVersion((string) $version) + ?? throw InvalidVersionException::unparseable($name, $version); + $this->ensureVersionExists($name, $version); if ($this->trackingConfig['enabled'] ?? false) { diff --git a/tests/Unit/PromptManagerTest.php b/tests/Unit/PromptManagerTest.php index 87abf3e..139e330 100644 --- a/tests/Unit/PromptManagerTest.php +++ b/tests/Unit/PromptManagerTest.php @@ -274,8 +274,6 @@ function freshManager(?array $configOverrides = []): PromptManager expect($meta['active_version'])->toBe(1); }); -use InvalidArgumentException; - test('activate() preserves existing metadata keys when updating active_version', function () { $this->createPromptFixture('preserve-meta', 1, 'sys', 'usr', null, [ 'name' => 'preserve-meta', @@ -573,3 +571,73 @@ function freshManager(?array $configOverrides = []): PromptManager ->and($versions[0]['metadata']['author'])->toBe('Alice') ->and($versions[1]['metadata']['description'])->toBe('Shared'); }); + +// ===================================================================== +// Version input formats — get() and activate() must agree +// ===================================================================== + +test('activate() accepts a v-prefixed version string', function () { + $this->createPromptFixture('str-activate', 1, 'sys'); + $this->createPromptFixture('str-activate', 2, 'sys'); + + expect(freshManager()->activate('str-activate', 'v2'))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/str-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() accepts a numeric version string', function () { + $this->createPromptFixture('num-str-activate', 1, 'sys'); + $this->createPromptFixture('num-str-activate', 2, 'sys'); + + expect(freshManager()->activate('num-str-activate', '2'))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/num-str-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() still accepts an integer version', function () { + $this->createPromptFixture('int-activate', 1, 'sys'); + $this->createPromptFixture('int-activate', 2, 'sys'); + + expect(freshManager()->activate('int-activate', 2))->toBeTrue(); + + $meta = json_decode(file_get_contents("{$this->tempDir}/int-activate/metadata.json"), true); + expect($meta['active_version'])->toBe(2); +}); + +test('activate() reports the offending value for an unparseable version', function () { + $this->createPromptFixture('bad-activate', 1, 'sys'); + + freshManager()->activate('bad-activate', 'banana'); +})->throws( + InvalidVersionException::class, + 'Invalid version [banana] for prompt [bad-activate]. Use a positive number like [1] or [v1].' +); + +test('activate() rejects a zero version', function () { + $this->createPromptFixture('zero-activate', 1, 'sys'); + + freshManager()->activate('zero-activate', 'v0'); +})->throws(InvalidVersionException::class, 'Invalid version [v0]'); + +test('get() reports the offending value for an unparseable version', function () { + $this->createPromptFixture('bad-get', 1, 'sys'); + + freshManager()->get('bad-get', 'banana'); +})->throws( + InvalidVersionException::class, + 'Invalid version [banana] for prompt [bad-get]. Use a positive number like [1] or [v1].' +); + +test('get() and activate() accept the same version formats', function () { + $this->createPromptFixture('parity', 1, 'sys v1'); + $this->createPromptFixture('parity', 2, 'sys v2'); + + $manager = freshManager(); + + foreach (['v2', '2', 2] as $input) { + expect($manager->get('parity', $input)->version())->toBe(2) + ->and($manager->activate('parity', $input))->toBeTrue(); + } +});