Skip to content

Releasing Application Prompts

The bundle target writes a prompt bundle for every @prompts entry. prs release turns those bundles into releases. It stores each bundle under its content hash, and it records which hash each environment, such as staging or prod, serves. An application reads the label of its environment, then the bundle file the label points at.

Read Application Prompts first. This page covers only the release flow.

Configuration

Releases are opt-in. List the environments in promotion order, first to last:

releases:
environments: [staging, prod]

Without releases, every prs release command fails with PS5001 and shows this snippet. See releases for the name rules.

policies are read by every release command. A policy with an unknown kind fails the command, it is never skipped. Policies parse the same way as in prs validate, against governance.roles, releases.environments and the model catalog with models.profiles, so a role, environment or model that validate refuses fails a release command too.

Files

All release files live in .promptscript/releases/ and belong in Git:

.promptscript/releases/
manifest.json bundle index of the latest build
bundles/sha256-<hex>.json one immutable file per content hash
staging.json label of the staging environment
prod.json label of the prod environment
  • A bundle file has exactly the bytes the bundle target writes. A release never rewrites it and never deletes it.
  • The manifest is a bundle index of the latest build. Its file entries point into bundles/.
  • A label has one entry per prompt, sorted by id, and lists each id once. The $schema is https://getpromptscript.dev/schema/prompt-release-label/v1.json.
{
"$schema": "https://getpromptscript.dev/schema/prompt-release-label/v1.json",
"environment": "prod",
"prompts": [
{
"id": "acme-support/refund-reply",
"contentHash": "sha256-<64 hex digits>"
}
]
}

Release files must be regular files. A symbolic link in .promptscript, .promptscript/releases, bundles/, or for a release file itself is refused. Names must match in case exactly: a Prod.json or Manifest.json is refused, because a case-insensitive disk would read it as the real file. A manifest or label that does not parse as one is never overwritten, the error tells you to restore it from Git.

prs compile refuses any output inside .promptscript/releases/ (PS5008), and watch mode ignores that directory. Compile cleanup never removes release files.

The flow

flowchart LR
  src[.prs source] -->|prs release build| bundles[bundles/ and manifest.json]
  bundles -->|promote --to staging| staging[staging.json]
  staging -->|promote --to prod| prod[prod.json]

Build

Terminal window
prs release build

Build compiles the source in memory. It never reads the compiled prompts/ directory, and it works whether or not bundle is in targets. It writes each new bundle file, checks the ones that exist, and writes the manifest only when its bytes change. A second run writes nothing. A program without prompts gives prompts: [].

A build always runs with these settings, so a release cannot ship what a normal compile would flag:

  • Policies are on. validation.skipPolicies: true and validation.ignoreHashes in the YAML are ignored. Each policy keeps its own severity, so only a policy with severity: error fails the build.
  • The security rules PS005 blocked-patterns, PS010 suspicious-urls, PS011 authority-injection, PS012 obfuscated-content, PS013 path-traversal and PS014 unicode-security run as errors, whatever validation.rules and validation.disableRules say. When the config switched one off, the error says so.
  • validation.excludes do not apply to these rules in a release, and imported text is scanned like local text. An import excluded from a rule in prs validate is still checked when it ships. validation.allowedPatterns does apply, like in every other command, so a pattern exempted there stays exempted.

An existing bundle file that does not verify fails with PS5004 and the advice to restore it from Git. Use --dry-run to see what would be written.

Promote

Terminal window
prs release promote acme-support/refund-reply --to staging
prs release promote acme-support/refund-reply --to prod

Promote points an environment at a bundle. The argument is <id> or <id>@<hash>. The hash is the full sha256-<64 hex> or a prefix of at least 8 hex digits, with or without sha256-, matched among the bundle files of that id. An ambiguous prefix lists the candidates.

The promotion order is fixed and has no escape:

  • The first environment recompiles the source like build does and accepts only the fresh hash. A hash you give must equal it. Without a hash, the manifest entry must equal it. Otherwise the command fails with PS5004 and tells you to run prs release build.
  • A later environment accepts only the hash the previous environment holds now. Otherwise the command fails with PS5005 and prints the exact promote command for the previous environment.

There is no --force, --skip-policies or --ignore-hashes. The bundle file must exist (PS5003) and verify with that id (PS5004).

Promote warns, without failing, when the new bundle changes inputs compared with the bundle the environment holds now: a new required input, an input that became required, a removed input, or a changed type or options. Callers need an update before the release reaches them.

Before the label is written, inside the lock, the prompt version must pass the release gates. The label is written atomically. Promoting the hash an environment already holds is a no-op with exit code 0.

Roll back

Terminal window
prs release rollback prod

Rollback undoes the last committed change of a label. It reads Git history, there is no history field in the label.

  • The label must have no uncommitted change, else PS5006 tells you to commit it or run git restore.
  • It takes the label as it was before its last commit. At the first promotion nothing is before it, and PS5006 says so.
  • In a shallow checkout the commit before may be missing. PS5006 then tells you to run git fetch --unshallow.
  • The restored label must parse and every hash in it must verify.
  • Every hash the rollback brings back must pass the release gates with the records committed now. An entry the rollback leaves as it is is not judged again.

A committed rollback is undone by a second rollback, like helm rollback. Rollback does not check the promotion order.

To go further back, restore the label from an older commit with Git. Promote cannot do it: the first environment takes only the fresh build and every later one only the hash of the environment before it. Find the commit with prs release status --history or git log, restore the file, check it, and commit it in a pull request like any other label change:

Terminal window
prs release status --history 10
git restore --source=<commit> -- .promptscript/releases/prod.json
prs release status

Every rollback PS5006 message points to this.

Status

Terminal window
prs release status
prs release status --history 5

Status lists every environment with the prompt id, the hash (12 hex digits), whether the hash is in the latest build (equals the manifest) and whether the bundle verifies. A missing label means an empty environment. A label file for an environment that is not configured gives a warning.

Every referenced bundle is verified, and a failure exits with code 1, so CI can run it. --history <n> (1 to 1000) adds the commit, date and subject of the last n commits of each label. It shows no author names.

With prompt policies, status also runs the release gates on every entry and adds an approvals column: ok, missing: <roles>, or - when no required-approvals policy applies. Plain status only reports: a gate that fails is a warning and the exit code does not change. The check that fails on it is --base.

One pull request per promotion

Every promotion is a reviewed pull request, and Git history is the release log:

  1. Change the prompts and run prs release build.
  2. Commit the changed .prs source, promptscript.yaml and every config file it reaches through extends together with the new bundle files and the manifest. Open a pull request and merge it. Promotion to the first environment recompiles the source with the config on the branch, so a build without them fails with PS5004.
  3. Run prs release promote <id> --to staging, commit the label, open a pull request.
  4. When staging looks right, run prs release promote <id> --to prod in a new pull request.

Roll back the same way: run prs release rollback prod, commit the label, open a pull request. prs release status --base lets that pass, see below. Going back further than the last change is a new promotion under the promote rules.

Promote, build and rollback take a project lock, .promptscript-release.lock. Another release command that holds it fails with PS5007. A stale lock is broken after its process is gone or after 30 minutes. Dry runs take no lock. Add the lock file to .gitignore.

Approvals

An approval is a small JSON file that says one person approved one exact prompt version in one role. prs approve writes it. The version is the content hash, so a record covers the messages, the model and the governance values of that bundle, nothing else.

Approvals need releases and the roles under governance.roles, each with the teams that review it:

releases:
environments: [staging, prod]
governance:
roles:
legal: ['@acme/legal']
security: ['@acme/appsec']

Approve the full hash, for one environment or for all of them:

Terminal window
prs approve --list acme-support/refund-reply
prs approve acme-support/refund-reply@sha256-<64 hex digits> --role legal --approver @jane --env prod
prs approve acme-support/refund-reply@sha256-<64 hex digits> --role security --approver @bob --all-environments --comment "Checked in PR 123"

--list prints the hash the checkout builds. The command checks, and fails without writing:

  • the role is in governance.roles (PS5101). A role named like a Windows device (con, nul, com1, …) is refused, since it names a directory.
  • the reference is <id>@sha256-<64 hex> with a valid prompt id (PS5102). A prefix is not enough, and no id segment may end with a dot.
  • --env is a configured environment (PS5002), or --all-environments is given. One of the two is required.
  • --comment has at most 2000 characters, no control characters except newlines and no invisible format characters such as bidi marks.
  • --approver is a Git host user handle like @jane (PS5104). An email address or a team is refused, so no address ends up in a file name.
  • the hash is real (PS5103): a bundle in .promptscript/releases/bundles/ of that prompt that verifies, or what the source builds right now. A stored bundle that does not verify is PS5004.

Before it writes, the command prints what the hash holds to stderr: the description, governance values, model, inputs with their defaults and options, output contract and messages. Invisible characters, such as tag characters or bidi marks, show as <U+XXXX>. Read the preview. If it is not what you meant to approve, delete the record file before you commit it. The command shows no diff against an earlier version: the pull request diff is that diff. Link it in --comment or in the pull request.

Record files

.promptscript/approvals/<role>/<prompt id>/<content hash>.<approver>.json
.promptscript/approvals/legal/acme-support/refund-reply/sha256-3f9a...jane.json

The approver part is the handle without @, lowercased. There is one file per approver, role and hash. The file holds exactly these bytes, with environment only when --env was given and comment only when one was given:

{
"$schema": "https://getpromptscript.dev/schema/approval-record/v1.json",
"prompt": "acme-support/refund-reply",
"contentHash": "sha256-<64 hex digits>",
"role": "legal",
"approver": {
"source": "asserted",
"value": "@jane"
},
"createdAt": "2026-10-22T09:14:03.120Z",
"environment": "prod",
"comment": "Checked in PR 123"
}

A record is never overwritten. When the file exists, the command fails with PS5105 and exit code 2. To withdraw an approval, delete the file in a commit. To change its environment or comment, delete it in a commit and approve again.

A record counts only with these exact bytes. The first prs approve writes .promptscript/approvals/.gitattributes with * -text, so a checkout with core.autocrlf never changes its line endings. Commit that file too. When the file exists without that line, the command warns and leaves it alone.

The path that counts is the one Git tracks, because CODEOWNERS matches that path, and the bytes must equal the ones Git has for that path, because those are the reviewed ones. On a case-insensitive disk, as on macOS and Windows, Legal/ and legal/ are one directory, so a record committed as Legal/... shows up under legal/, and two such paths share one file on disk. --list therefore compares the path in Git and the blob Git has for it, not only what the disk shows.

The writer walks the record path from the project root. A symbolic link, a file where a directory should be, or a name that differs only in case, like Legal/ next to legal, fails with PS5109. It creates the record from a temporary file and links it into place, so an existing name is never replaced. It takes the release lock .promptscript-release.lock, like build and promote.

Listing approvals

Terminal window
prs approve --list
prs approve --list acme-support/refund-reply --format json

--list compiles the source in memory, reads every record, asks Git with git ls-files -s which paths it tracks and which blob each holds, and prints, per prompt, the hash the checkout builds and each record with its role, approver, scope (prod or all environments) and state. When there are record files, it needs Git and a repository, else PS5108.

StateMeaning
currentThe record approves the hash the checkout builds
labelled:<env>Not the current hash, but the label of that environment holds it
staleNeither: the prompt changed since
untrackedNot added to Git yet. A staged record already counts as tracked
modifiedThe bytes on disk are not the ones Git has for the path
misplacedGit tracks the file at another path than its fields give
unknown-roleThe role is not in governance.roles

Only current and labelled records can count. A file that is no valid record, for example one with CRLF line endings, gets a PS5109 warning, and so do modified, misplaced and unknown-role records. The file name is never trusted alone: the fields decide, and the path in Git must match them. A record with a signature gets a warning, since signatures are not checked in this version. Links are never followed. At most 10000 files and directories, files of up to 64 KiB each, and 8 directory levels below .promptscript/approvals/ are read. Git’s list of record paths may take up to 10 MiB; a longer one fails with PS5108.

With --format json, stdout holds one document that matches https://getpromptscript.dev/schema/approval-report/v1.json.

What an approval proves

In this version a record is a claim. The approver is whatever --approver said, and createdAt is the clock of the machine that wrote it. The release gates count records, but only committed ones at their own path.

The binding review is the one on the Git host: the pull request that adds the record, at the path Git has for it. Give each role directory its own code owners, so a record under legal/ cannot merge without a review from @acme/legal. prs governance codeowners writes these lines from governance, see CODEOWNERS from Governance. By hand they look like this:

/promptscript.yaml @acme/prompt-platform
/shared/base.yaml @acme/prompt-platform
/.promptscript/releases @acme/prompt-platform
/.github/CODEOWNERS @acme/prompt-platform
/.promptscript/approvals @acme/prompt-platform
/.promptscript/approvals/legal @acme/legal
/.promptscript/approvals/security @acme/appsec

The admin lines cover what decides which review a record needs: the config and every file its extends reaches (here shared/base.yaml), the release files and the CODEOWNERS file itself.

Turn on required code owner review for the branch. The host also stops a pull request author from approving their own pull request; the CLI does not check that. For “who approved this for prod”, prs audit log names the record and the commit that added it; read the review of the pull request of that commit. A well-formed record there only has the right bytes at the right path. It does not mean a gate accepts it, and the review by the role team lives in the review log of the Git host, not in Git.

Release gates

The gates are the prompt policies, run on the prompt version a label is about to hold. They run in prs release promote before the label is written, in prs release rollback for every hash it brings back, in prs release status --base for every entry that changed, and in plain prs release status for every entry, where they only warn. There is no flag or setting around them: they call the policy evaluators themselves, so validation.disableRules, validation.rules, custom rules and --skip-policies do not apply. validation.blockedPatterns and validation.allowedPatterns do apply, like everywhere else.

Without a prompt policy the gates pass and nothing else runs. With one:

  • The policies are parsed again. A policy that does not parse fails every gate.
  • The source is built in memory, with the same settings as prs release build. A source that does not build fails the gates, also for a rollback: fix the source, or change the policy in a reviewed pull request.
  • The policy risk is the highest of the bundle’s risk, the source’s risk and the floors of both. match sees the domain of either and the data classes of both. A bundle file only verifies against its own hash, so a bundle with lowered governance values still falls under the policies the source falls under.
  • required-metadata, risk-floor, model-allowlist and content-rules judge the bundle that ships: its governance, model, inputs and messages.
  • required-approvals counts approval records for that prompt, hash and environment. required-evals is unavailable, see Policy Engine.

A check of severity error that fails, or is unavailable, fails the command with a PS030 error that names the policy and what to do. A check of severity warning is a warning. The skill policy kinds are not part of the gates, they run as PS030 in the source build.

Which approvals count

The gates read the records committed at HEAD, with git ls-tree -r HEAD, never the disk or the index. A record that is only staged, or edited and not committed, does not count. prs approve --list reads the index, so it shows a staged record as current while the gates do not count it yet.

status --base on a pull request counts the records the pull request adds, and the gate cannot tell who wrote them. A record binds only through review: each .promptscript/approvals/<role>/ needs that role’s team as code owners, with required code owner review on the branch (see What an approval proves). prs governance codeowners writes those lines, see CODEOWNERS from Governance. Without it, any author can add a record in another person’s name and pass the gate.

A record counts for one approver when:

  • its fields name the prompt, the hash and the role, and environment is the one promoted to, or absent;
  • its committed path is the one its fields give, .promptscript/approvals/<role>/<prompt id>/.... CODEOWNERS protects records by that path, so a record anywhere else had the wrong reviewers;
  • the role is still in governance.roles;
  • the bytes are a valid v1 record.

An approver counts once per role, compared lowercased. A record of the same prompt and role for another hash shows as stale. Two committed paths under .promptscript/approvals/ that differ only in case fail the gates with PS5109: a case-insensitive disk keeps one of them. A committed file over 64 KiB is no record and does not count. When Git cannot list or read the records, for example git is not on PATH or an object is missing, the gates fail with PS5108 and do not count zero approvals.

prs release status --base <ref> takes the policies, governance and releases of <ref>, and the records committed at HEAD.

Output

Gates for acme-support/refund-reply@sha256-3f9a1c2b4d5e to prod (policy risk: high)
passed high-risk-models model-allowlist
failed high-risk-signoff legal 0 of 1 (stale: @bob for sha256-91bc0d2e1a2b)
unavailable prod-evals acme-support/refund-reply needs passing evals for prod, but release gates do not read eval results in this version of prs
Promotion refused. Label prod is unchanged.

With --format json, the report holds every result under gates, with the status, message and, for approvals, the approvers found and the stale records per role.

In CI

Terminal window
set -e
prs release build
test -z "$(git status --porcelain -- .promptscript/releases)"
prs release status --base refs/remotes/origin/main

Build writes whatever the committed release files miss, so the test -z check fails when a pull request changes prompts without the build. It also catches new bundle files, which git diff --exit-code would not see because they are untracked. status fails when a label points at a bundle that is missing or does not verify. All four commands accept --format json, which prints one document on stdout that matches https://getpromptscript.dev/schema/release-report/v1.json, and prints the human text on stderr.

Check label changes with --base

prs release status --base <ref> compares the label files in the checkout with the labels at <ref>, usually the branch the pull request goes into. It looks at every label file: the environments configured at <ref>, the ones configured in the checkout and every other *.json file in .promptscript/releases/ except manifest.json. A file whose text did not change passes. A changed file that does not read or parse as a label fails. Every entry that is new or changed must pass the promotion rules of <ref>:

  • The environment is in releases.environments at <ref>.
  • The bundle file exists and verifies for that prompt id.
  • The entry is a rollback: the label at <ref> holds the entry, and its new hash is the one the label held before its last change at <ref>, the label prs release rollback restores. This also covers a git revert of the last promotion of one environment. An entry that <ref> removed is not brought back this way; adding it again goes through the other rules.
  • Or, in the first environment, the hash is what the source in the checkout compiles to with the policies, validation and models.supported of <ref> and the forced security rules, the same compile prs release promote runs.
  • Or, in a later environment, the hash is the one the environment before it holds in the checkout.

A rollback skips the promotion order and the compare with the fresh hash. That is safe only when every commit on <ref> is the result of a pull request this check ran on, see the precondition below: then the hash passed the rules when it was promoted. The release gates still run for it, so the hash a rollback restores needs its approvals. A rollback can also restore a hash that the compile rules at <ref> (validation, the forced security rules, models.supported) reject today; the gates catch only what the prompt policies check, and review the rest. So when you take a prompt down after a rule change, do it in two pull requests: remove the entry, then promote the fixed version. Deleting the old bundle file does not help, a pull request can add it back from history. Reviewers should look at a rollback like at any other label change. A removed entry ships nothing; it is listed with action: "removed" and not checked.

Any failure exits with code 1. The config is read at <ref>: the first of promptscript.yaml, promptscript.yml, .promptscriptrc.yaml and .promptscriptrc.yml that exists in the project root, the same order the loader uses, with its extends chain read at the same commit. -c and PROMPTSCRIPT_CONFIG are ignored with --base, with a warning. So a pull request cannot loosen the rules it is checked with: a new policy, a removed policy, a wider models.supported or a new environment takes effect after the merge. Add a new environment in a pull request of its own, before the first label for it.

The rest of the config comes from the checkout: registry, registries, input.entry, models.profiles (they describe the models and reach the bundle hash), and the lockfile and the vendor directory. A pull request can change those, so review them with the labels.

<ref> must name one ref. A short name that matches a tag and a branch, for example a tag called origin/main next to the remote branch, fails with PS5006; pass the full name such as refs/remotes/origin/main.

In GitHub Actions, run it on pull_request events. Check out the history so the base exists, pass the base branch through env, not inline in run, and run the CLI version the lockfile pins:

on: pull_request
jobs:
releases:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm ci
- run: npx prs release status --base "refs/remotes/origin/$BASE_REF"
env:
BASE_REF: ${{ github.base_ref }}

What --base does not do:

  • Entries that did not change are not checked again, even when the rules at <ref> changed since they were promoted.
  • Going back further than the last change of a label is no rollback. It needs a new promotion under the promote rules, in the first environment with the source reverted.
  • Config files and label files at <ref> must be regular files. A config that is a link or a directory fails with PS5001; a label that is a link counts as empty, so every entry in the checkout is checked.
  • A config file that extends reaches outside the repository cannot be read at <ref>; the check fails with PS5006.
  • It trusts <ref>. Point it at the protected branch the applications read from.
  • The rollback rule trusts the history of <ref>: every commit on its first-parent chain must be the result of a pull request this check ran on. With rebase merging, or a fast-forward of a branch with several commits, an unchecked commit lands on that chain, and its label becomes a valid rollback target. Allow only squash or merge commits on that branch, see below. Label history from before the check was required counts as unchecked too: a label whose last change predates the check has an unchecked rollback target until its next change.
  • In a pull_request run the pull request controls the workflow file, package.json and the lockfile, so it can swap the CLI that runs the check. Protect them as below, or run the check as a required workflow from a repository ruleset.

Read a release in your application

The @promptscript/runtime package loads the bundles this flow writes. It has no dependencies. It is not published to npm yet and its API can still change, so it is in the repository only.

import { createPromptClient, toAnthropicMessages } from '@promptscript/runtime';
import { directorySource } from '@promptscript/runtime/node';
const client = createPromptClient({
source: directorySource('.promptscript/releases'),
label: 'prod',
});
const prompt = await client.get('acme-support/refund-reply');
const body = toAnthropicMessages(prompt.render({ message: 'Where is my refund?' }), {
maxTokens: 1024,
});

Every load checks the schema, the structure and the contentHash, and verification cannot be turned off. render() inserts input values as plain text. See the runtime README for the cache, the fail modes and the transport rules of httpSource.

What protects the labels

Run prs release status --base as a required check, and keep review on the release files:

  • Add .promptscript/releases/, promptscript.yaml, every config file it reaches through extends, .github/workflows/, package.json and the lockfile to CODEOWNERS, and require the review. prs governance codeowners writes these lines for the admins, see CODEOWNERS from Governance; add .github/workflows, package.json and the lockfile to governance.codeowners.protect.
  • Protect the branch the applications read from with a ruleset: require the prs release status --base check, allow only squash merges or merge commits (no rebase merging, no fast-forward of branches with several commits) and no direct pushes. The rollback rule relies on that.
  • An older label served from a cache, or a label on a rewritten branch, cannot be detected in this version.

Applications read the label file of their environment and the bundle file it names. Recompute the content hash before use, as described in Application Prompts.

Audit log

prs audit log reads the governance history of the project from Git: who changed a prompt file, who approved which hash, who promoted, rolled back or removed a label entry, and who changed the config or the generated CODEOWNERS section. Nothing is stored besides the repository, so the log is as good as the history on the branch.

Terminal window
prs audit log --prompt acme-support/refund-reply --env prod --hash 850dce44
head 4452d509b67930043f5155036a07f5e1f52d9183
sha256-850dce44fbd1...2b73 2026-10-22T09:15:40Z promoted 4452d509b679 acme-support/refund-reply env=prod previous=- by Ann Lee <[email protected]>
sha256-850dce44fbd1...2b73 2026-10-21T14:02:11Z approved 9ffb3347d0a8 acme-support/refund-reply role=legal approver=@jane (asserted) env=prod state=well-formed .promptscript/approvals/legal/acme-support/refund-reply/sha256-850dce44fbd1...2b73.jane.json by Jane Roe <[email protected]>
comment: Refund wording checked

The hashes are shortened here; the command prints them in full. That is the answer to “who approved this hash for prod”: the approved line names the approver the record claims and the commit that added it. The binding reviewer is in the review log of the Git host, for the pull request of that commit.

Events

Events come newest commit first; inside one commit they follow the path order of Git. Each one has type, commit, at (committer date), author, committer (name and email as Git has them), path (relative to the repository top level) and mainline. mainline is true for a commit on the first-parent chain of HEAD. An event with mainline: false is history of a branch: its change may never have reached HEAD, or reached it only through a merge.

TypeWhenExtra fields
changeda .prs file of the project was added, modified or deletedchange (A, M, D)
approvedan approval record was addedprompt, contentHash, role, environment, approver, approverSource, recordCreatedAt, comment, state
approval-removedan approval record was deleted, read from the parent committhe same
promoteda label entry was added or changedenvironment, prompt, contentHash, previousContentHash
rolled-backa label entry was set to a hash it held before in that labelthe same
label-removeda label entry was removed, or the label file deletedenvironment, prompt, previousContentHash
merge-droppeda merge did not keep a change of a parent after the firstparent, change, and the record fields, or environment, prompt, contentHash, droppedContentHash
config-changeda config file name of the project, the config or its extends changedchange
generatedthe CODEOWNERS section under this project’s marker changednone

A record file changed in place shows as approval-removed with the old content and approved with the new. state comes from the record bytes and the path alone: well-formed at the path its fields give, misplaced at another path, invalid when the file is no v1 record in the exact form prs approve writes, is a link or a submodule, or is larger than 1 MiB (an invalid record has no record fields). Well-formed does not mean a gate accepts the record: the gate also needs the role in governance.roles and the scope, and the review by the role team lives in the review log of the Git host. A misplaced or invalid record never counts in a gate. approverSource is always asserted.

Commits of merged side branches are read too, each with its own events. A merge commit is read against its first parent: its events are what it brought to the branch it was merged into, and they carry viaMerge: true. So a promotion made on a side branch shows twice, once on the side commit and once on the merge with viaMerge. A merge that takes an older version over from the side, such as a revoked record or an older label hash, shows that as approved or rolled-back on the merge. Whether a label entry is rolled-back is decided by the hashes the label held on the first-parent chain, so a side branch never makes a later promotion look like a rollback. Side branches a merge discarded, for example with -s ours, still show their own events.

The first-parent diff cannot show what a merge threw away from its other parents. So for every merge and every parent after the first, the log compares that parent with the merge base and with the merge result. When the parent changed an approval record, a label entry or the generated CODEOWNERS section and the merge result does not have that change, the merge gets a merge-dropped event naming the parent. A revoked record that a merge brings back, or a promotion it resolves away, shows up this way even when the branch is fast-forwarded later, and for octopus merges too. For a label the event names the hash the parent had (droppedContentHash) and the hash the merge kept (contentHash); for a record, change says what the parent did (A, M or D) and the record fields come from the parent’s version, or from the merge’s when the parent deleted it.

After reading the history, the command replays the mainline events and compares the result with the approval records and label entries at HEAD. Any difference fails with PS5108: the history has a change the log cannot explain, and the log is not printed as if it were complete. Labels that did not parse at some commit are left out of this check.

The log reads the whole project directory, keeping the .prs files, .promptscript/approvals/, every .promptscript/releases/<environment>.json label, every config file name in the project root, and outside the directory the config and its extends files inside the repository, the files a --prompt prompt is built from and the four CODEOWNERS locations. A label of an environment that is not in releases.environments is read too, with a warning, and its events carry configured: false, so a look-alike file such as prodd.json stands apart from prod.

Filters

OptionKeeps
--prompt <id>approval and release events of the prompt, and changed events of the files it is built from in the checkout
--env <env>release events of the environment, and approvals for it or for every environment
--hash <prefix>events whose contentHash (for label-removed, previousContentHash; for merge-dropped, either hash) starts with these hex digits (sha256- optional, at least 4 digits)
--since <date>events committed at or after the ISO date or date and time
--until <date>events committed at or before it; a date alone means the end of that day, UTC
--limit <n>the newest n events after the other filters, at most 100000

An event without the filtered field does not match: with --hash, only approval and release events remain. --prompt finds the files of a prompt by resolving the checkout, so a prompt file of an older layout is not matched; an invalid record matches when it lies in the prompt’s directory under a role. Filters apply after the whole history is read, so rolled-back stays right inside a time window.

Formats

--format text (the default) prints a head <commit> line, then one line per event with the content hash first (- for events without one), (asserted) after the approver, and [via merge], [side branch] or [environment not configured] where they apply. --format json prints one document that matches https://getpromptscript.dev/schema/audit-log/v1.json: $schema, version, success, head (always there on success), filters, events, warnings and errors. C1 control, bidi and zero-width characters in strings are written as \uXXXX escapes.

--format cef prints one ArcSight CEF line per event for a SIEM:

CEF:0|PromptScript|prs|<cli version>|prs-approved|Prompt approved|5|rt=1792591331000 act=approved [email protected] duser=@jane filePath=... cs1=acme-support/refund-reply cs1Label=prompt cs2=sha256-... cs2Label=contentHash cs3=legal cs3Label=role cs4=prod cs4Label=environment cs5=9ffb3347... cs5Label=commit outcome=well-formed msg=Refund wording checked flexString1=4452d509... flexString1Label=head flexString2=asserted flexString2Label=approverSource
KeyValue
rtcommit time in milliseconds
actevent type
suserauthor email
duserapprover
filePathpath
cs1 to cs6prompt, contentHash, role, environment, commit, previous contentHash (the parent for merge-dropped)
outcomerecord state, or added, modified, deleted for file changes
msgrecord comment
flexString1head, the commit the log was read from
flexString2approverSource, always asserted, on approval events
cn11 with cn1Label=viaMerge on events of a merge
cn20 with cn2Label=configured on labels of unconfigured environments
cn31 or 0 with cn3Label=mainline
oldFileHashdroppedContentHash of a merge-dropped label entry

Severities: changed and generated 3, approved 5, approval-removed, promoted and config-changed 6, rolled-back, label-removed and merge-dropped 7. Header values escape \ and |; extension values escape \ and =, line breaks become \n, and other control characters are dropped. Text and CEF output drop control, bidi and zero-width characters from Git and record fields.

Limits

  • Author, committer and approver are what the commit and the record say. Git does not check them. The committer date is set by the committer’s machine too, so --since and --until filter on a claim.
  • A squash merge shows the person who merged, not the authors of the branch.
  • The log reads the project directory where it is now. History from before the directory moved is not read: after a move the log starts at the move commit, and approvals, labels and prompt files from before it show up as added there, by the person who moved them.
  • The review identity of the Git host is not in Git. Join on the commit to read it.
  • Rewriting history changes the log. Block force pushes on the branch, and ship exports to a SIEM on a schedule; head in every export tells two exports apart.
  • The log reads the project directory as a whole. For a project at the repository root that is every commit of the repository, and a very large monorepo can pass the 256 MiB the command reads from git log, which fails with PS5108.
  • A shallow clone, a missing git, a timeout or a blob a partial clone would fetch fails with PS5108. In CI check out the full history, for example actions/checkout with fetch-depth: 0.
  • Git runs with replace refs and grafts off, so a git replace ref or a .git/info/grafts file cannot change what the log shows.
  • The output holds email addresses. Treat exports like other personal data.

Error codes

CodeMeaning
PS5001releases is missing or invalid
PS5002The environment is not in releases.environments
PS5003A bundle or prompt is missing, or a hash prefix matches nothing
PS5004A release file does not verify, or the hash is not the fresh hash
PS5005The promotion skips the previous environment
PS5006Git cannot give rollback or --base the ref, commit or file
PS5007Another release command holds the lock
PS5008A compile output lies inside .promptscript/releases/

prs approve uses these codes:

CodeMeaning
PS5101governance.roles is missing or invalid, or the role is not in it
PS5102The reference is not <id>@sha256-<64 hex> with a valid prompt id
PS5103The hash is neither what the checkout builds nor a bundle of that prompt
PS5104--approver is missing or not a user handle like @jane
PS5105The record exists (exit code 2)
PS5108--list cannot run Git, or the project is not in a repository
PS5109A record is invalid, modified, misplaced or has an unknown role, or the record path has a link

prs audit log uses PS5102 for a --prompt or --hash value of the wrong shape and PS5108 when Git cannot give the history: no repository, no commit, a shallow clone, a timeout, a missing blob, or a history whose mainline events do not lead to the records and labels at HEAD.

See Also