@softure-ai/compat answers one question before a server release: is the revision about to be released backward
compatible with the release running in production? It compares two git refs of your repository (for example the
production tag and HEAD) layer by layer, classifies every change, and fails a CI gate when something would break
clients, data or a rollback.
It works only from git. It never connects to a production server or database.
| Layer | What it compares |
|---|---|
openapi |
the HTTP contract, through oasdiff |
sql-migrations |
new SQL migrations (folders such as drizzle, or EF Core idempotent scripts), Postgres and SQL Server |
seed |
seed scripts that run on every deploy, row by row |
error-codes |
typed error codes new in the revision that live client builds cannot translate |
persisted-enums |
enums stored in the database as strings or numbers (C# and TypeScript) |
config |
configuration keys a release needs (compose interpolation, .env examples, your own patterns) |
dependencies |
runtime package versions (NuGet, npm), classified by semver |
outbound |
external API hosts and paths the revision starts calling, which production must allow |
message-contracts |
C# message contracts (types, wire properties, enums) and queue names, for messages in flight |
behaviour |
the base ref's black-box tests, run against the revision's running stack |
npm install --save-dev @softure-ai/compatNode.js 22 or newer and git are required. The openapi layer uses oasdiff v1.33.0. You do not have to
install it: the CLI looks for oasdiff in layers.openapi.oasdiff.path, the SOFTURE_COMPAT_OASDIFF environment
variable and PATH, and when none has it, downloads the pinned v1.33.0 release for your OS and CPU from
github.com/oasdiff/oasdiff/releases. The archive is checked against
a SHA-256 shipped inside this package before it is used, and the binary is cached, so later runs work offline.
A cached binary is used only while it matches its shipped SHA-256, also when downloading is turned off.
| Topic | Details |
|---|---|
| Platforms | macOS (Intel and Apple silicon), Linux x64 and arm64, Windows x64 and arm64 |
| Cache | SOFTURE_COMPAT_CACHE_DIR, else $XDG_CACHE_HOME/softure-compat or ~/.cache/softure-compat (%LOCALAPPDATA%\softure-compat on Windows) |
| Turn it off | --no-download, SOFTURE_COMPAT_NO_DOWNLOAD=1, or "oasdiff": { "download": false }; the verified cache is still used |
| Behind a proxy | Node.js reads HTTPS_PROXY when NODE_USE_ENV_PROXY=1 is set |
A download that fails or does not match the checksum fails the openapi layer; it never passes unchecked. With
downloading turned off and no oasdiff found, the layer is skipped, and a skipped layer fails the gate unless you
pass --allow-incomplete. The report notes which oasdiff ran and where it came from. To install oasdiff yourself:
go install github.com/oasdiff/oasdiff@v1.33.0npx softure-compat init # writes compat.config.json from the files committed at HEAD
npx softure-compat check # compares the refs in its "check" section; Markdown report on stdout, exit code for CIinit looks at the files committed at HEAD and writes every layer into compat.config.json. A layer whose inputs
it found is enabled; any other layer is written with "enabled": false and an example, so you can see what to fill
in. Review the file, adjust it, and remove "enabled": false from the layers you want. init never overwrites an
existing file unless you pass --force.
init also writes the refs check compares by default (see Configuration). It reads the
repository's GitHub deployments (with the token and repository the resolvers use):
the two environments with the most recent successful deployments become base and revision, the one named like
prod being the base, otherwise the one deployed less recently. A single deployed environment is compared with
HEAD. With no successful deployment, or when GitHub cannot be read, it writes latest-tag and HEAD and says why.
What init detects:
| Layer | Enabled when the commit has |
|---|---|
openapi |
openapi* or swagger* files (.json, .yaml, .yml); each becomes a file source |
sql-migrations |
.sql files mentioning __EFMigrationsHistory (EF Core idempotent scripts), or drizzle meta/_journal.json folders for PostgreSQL |
seed |
.sql files with seed in the name that are not migrations |
persisted-enums |
a *DbContext.cs calling ConfigureEnum<T>() (string storage) |
config |
compose files and .env examples at the default globs, without test and mobile app files; compose files the deploy tooling references win over the rest. Ansible templates (KEY={{ var }}), lookup('env', 'KEY'), assert tasks and workflow env: entries from secrets.* become regex sources joined in a deploy chain (the assert is required); a workflow environment: adds a gh secret list presence command |
dependencies |
MSBuild files and package.json files, without React Native and Expo apps; with NuGet, test packages (Microsoft.NET.Test.Sdk, xunit*, nunit*, MSTest*, coverlet.*, *.Analyzers, Microsoft.CodeAnalysis.*) are written as ignore |
message-contracts |
C# files under a folder whose name contains Contract or ends with Messages, one glob per such folder, kept when the folder name ends with Messages or Events or when an IConsumer<T>, ConsumeContext<T>, IRequestClient<T>, Publish/Send<T> or Publish/Send(new T ...) in the repository names one of its types; request DTO folders are left to openapi, and with none kept the layer is written disabled |
behaviour |
never: it is written disabled with example commands, since nothing in a repository says how its stack starts |
Folders named node_modules, bin, obj and dist are ignored. A test file is one under a test, tests,
e2e, mocks or *.Tests folder, or with such a word in its name (docker-compose.integration-tests.yml); a
mobile app is the folder of a package.json that depends on expo or react-native. Ansible files are those under
an ansible, roles or playbooks folder or next to an ansible.cfg; stderr lists every file init skipped. A SQL file is read as SQL Server when it has GO
batch lines or [dbo] names, otherwise as Postgres.
softure-compat check [--base <ref>] [--revision <ref>] [options]
softure-compat init [--repo <dir>] [--config <file>] [--force]
| Option | Command | Meaning |
|---|---|---|
--base <ref> |
check | git ref running in production, or a resolver (default: check.base in the config) |
--revision <ref> |
check | git ref about to be released, or a resolver (default: check.revision in the config) |
--repo <dir> |
both | repository directory (default: current directory) |
--config <file> |
both | config file (default: <repo>/compat.config.json) |
--format <md|json> |
check | report format (default: md) |
--output <file> |
check | write the report to a file instead of stdout |
--json-output <file> |
check | also write the JSON report to a file, from the same run |
--fail-on <class> |
check | breaking, rollback-risk, needs-action or never (default: check.failOn in the config, then breaking) |
--allow-incomplete |
check | do not fail when a layer was skipped or failed |
--require <layer,...> |
check | fail the gate unless these layers ran: disabled, not configured, skipped or failed all fail it, even with --allow-incomplete |
--no-download |
check | never download oasdiff; use the verified cache, else skip the openapi layer |
--log-dir <dir> |
check | where layers keep the full output of the commands they run, one folder per layer (default: <cache>/logs/<run>, the cache being SOFTURE_COMPAT_CACHE_DIR or ~/.cache/softure-compat) |
--force |
init | overwrite an existing config file |
-h, --help |
both | show the help |
-v, --version |
both | show the version |
Exit codes: 0 the gate passed (init: the config was written), 1 the gate failed, 2 the command could not
run (bad arguments, invalid config, a base or revision set neither on the command line nor in the config, unknown
ref, a resolver that found nothing, unreadable repository).
--base must be what production runs, and the newest tag usually is not (it is often what DEV runs). Instead of a
git ref, --base and --revision take a resolver:
| Value | Resolves to |
|---|---|
github-deployment:<environment> |
the commit of the newest GitHub deployment to that environment whose newest status is success (superseded deployments are inactive and skipped) |
github-workflow:<file> |
the head commit of the newest successful run of that workflow, e.g. github-workflow:deploy-prod.yml |
latest-tag[:<glob>] |
the newest tag matching the glob (all tags without one), in version order, e.g. latest-tag:v2.* |
The GitHub resolvers read the token from GH_TOKEN, then GITHUB_TOKEN, then gh auth token; the repository from
GITHUB_REPOSITORY, then the origin remote; the API from GITHUB_API_URL (default https://api.github.com). A
resolver that finds nothing stops the check with exit code 2; it never falls back to a guess. The resolved commit
must be in the clone, so fetch the full history. The report header names both and where the value was set, e.g.
"Base github-deployment:prod → 2.2.4 26b8973e1f0a (config)" or "(--base)" when the flag set it; the JSON report
carries "source": "config" or "flag". A branch literally named latest-tag has to be passed as
refs/heads/latest-tag.
Every finding has one class, from least to most severe:
| Class | Meaning |
|---|---|
safe |
old clients and the old build keep working |
needs-action |
works, but someone must do something or check something before the deploy (a secret, a precondition on data) |
rollback-risk |
the release works, but after it writes new data, rolling back to the base build breaks |
breaking |
existing clients or the base build break as soon as the revision is deployed |
The gate fails when an unaccepted finding is at or above --fail-on, or when a layer was skipped or failed
(unless --allow-incomplete). A failed layer still reports the findings it produced.
A layer that is "enabled": false (disabled) or missing from the config (not configured) does not fail the gate,
but the report never hides it: it gets a row in the summary table, a Not checked: openapi (disabled), ... line under
the gate, and an entry with status disabled or not-configured in the JSON layers. A pass without openapi says
nothing about the HTTP contract, so a pipeline that relies on a layer names it with --require (e.g.
--require openapi,sql-migrations).
Each layer accepts known findings with an accept list: every entry needs a reason, accepted findings stay in the
report under "Accepted", and an entry that matched nothing is reported as a note so stale entries get cleaned up.
The GitHub Action runs the check, writes the report to the job summary and keeps one comment with the report on the
pull request, updated on every push. needs-action findings do not fail the default gate, so the comment is where the
person merging the release sees them.
on: pull_request
permissions:
contents: read
deployments: read # github-deployment:<environment>
actions: read # github-workflow:<file>
pull-requests: write # the report comment
jobs:
compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # both refs must be in the clone
- uses: SOFTURE/COMPAT@v0.14.0 # base, revision and fail-on come from "check" in compat.config.jsonUse the tag of a release (@v0.14.0); every release has one. The action runs the CLI version released
from the same commit, so the action and the CLI never drift apart.
| Input | Default | Meaning |
|---|---|---|
base |
check.base |
--base: a git ref or a resolver |
revision |
check.revision |
--revision |
fail-on |
check.failOn, then breaking |
--fail-on |
config |
compat.config.json |
--config, relative to working-directory |
working-directory |
. |
the repository directory |
args |
extra CLI arguments, split on whitespace (--allow-incomplete --no-download) |
|
comment |
true |
create or update the report comment on pull requests |
comment-key |
default |
one comment per key, for several checks on one pull request |
artifact |
true |
upload the JSON report and the command logs (--log-dir) as the artifact softure-compat-report-<comment-key> |
package |
the released version | npm package spec of the CLI to run instead (a version or a tarball path) |
node-version |
22 |
Node.js set up for the CLI; empty keeps the job's Node.js |
github-token |
github.token |
token for the resolvers and the comment |
Outputs: exit-code (0 passed, 1 gate failed, 2 could not run), report (path of the Markdown report) and
json-report (path of the JSON report from the same run). The step
fails when the gate fails, after the summary and the comment are written. Without pull-requests: write (for example
on a pull request from a fork) the comment is skipped with a warning and the summary still has the report.
Without the action, run the CLI yourself. The check needs both refs in the clone, so fetch the full history (or at least the production tag):
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx softure-compat check --base github-deployment:production --revision HEAD --output compat-report.md
env:
GH_TOKEN: ${{ github.token }}The job needs permissions: { contents: read, deployments: read, actions: read } for the GitHub resolvers. A
literal ref (--base "$PRODUCTION_TAG") works too.
No Go toolchain is needed: the first run downloads the pinned oasdiff (see Install). Cache
~/.cache/softure-compat with actions/cache to skip the download, or pass --no-download on runners without
internet access: the cached oasdiff is still used, and without one provide oasdiff yourself.
--format json gives a machine-readable report with the same content. To get both from one run, add
--json-output <file>: check --output compat-report.md --json-output compat-report.json writes the Markdown and the
JSON report without running the layers twice.
The Markdown report keeps what needs a decision in front: safe and accepted findings show a count per layer and rule
(error-codes: 36 × error-code-unknown-to-client, 18 × error-code-added) and fold the findings into a <details>
block. Findings of several layers about one thing (a seed row writing a new enum member and that member) are one
entry with each layer's view under it, and evidence seen at several refs is one link with the list of refs. The JSON
report keeps every finding as it is.
The summary table has one row per layer with its verdict (its most severe finding that is not accepted), the count of
findings per class and the count of accepted ones, so a layer whose findings were all accepted reads no-findings with
its accepted count next to it. A layer with no findings of its own that changed the class of other layers' findings
(client-usage) reads reclassified 6. The JSON report gives each layer acceptedCount and reclassifiedCount;
its verdict stays the gate's view. Accepted findings never fail the gate.
check and init use compat.config.json in the --repo directory (default: the current directory), or the
file --config names. Unknown keys are errors, so a
typo never silently disables a check. Every layer is optional; a configured layer runs unless it has
"enabled": false. All paths and globs are relative to the repository root; globs support **, *, ? and
{a,b}.
The optional check section holds the defaults of check: base, revision (a git ref or a
resolver) and failOn. Each command-line flag overrides its key, so softure-compat check needs no flags once they are set; a base or revision set in neither place is exit code 2.
{
"check": { "base": "github-deployment:prod", "revision": "github-deployment:dev", "failOn": "breaking" },
"layers": {
"openapi": { "apis": [{ "name": "public", "source": { "kind": "file", "path": "api/openapi.yaml" } }] },
"sql-migrations": { "sources": [{ "name": "db", "dialect": "postgres", "kind": "folder", "path": "drizzle" }] },
"seed": { "sources": [{ "name": "db", "dialect": "postgres", "files": ["db/seed.sql"] }] },
"persisted-enums": {
"sources": "**/*.cs",
"enums": [{ "kind": "discover", "files": "**/*DbContext.cs", "pattern": "ConfigureEnum<(?<name>[\\w.]+)>", "storage": "string" }]
},
"config": { "sources": [{ "kind": "compose" }, { "kind": "dotenv" }] },
"dependencies": { "watch": [{ "name": "SOFTURE.*", "class": "needs-action" }] }
}
}Compares the OpenAPI spec of each API at both refs with oasdiff changelog. oasdiff levels map to classes: error →
breaking, warning → needs-action, info → safe. Finding ids are oasdiff check ids (for example
endpoint-added, request-property-became-not-nullable); see the
oasdiff checks.
{
"apis": [
{
"name": "b2c",
"source": { "kind": "command", "run": "dotnet run --project src/Api -- export-openapi openapi.json", "output": "openapi.json" },
"accept": [
{
"id": "request-property-became-not-nullable",
"operation": "POST /api/pets/{petId}/medications",
"reason": "the old app always sends an array; the handler does ?? []"
}
]
}
],
"oasdiff": { "path": "tools/oasdiff", "args": ["--exclude-elements", "description"] }
}Several APIs built from one solution can share one build per side:
{
"setup": { "run": "dotnet build App.slnx -c Debug", "timeoutSeconds": 900 },
"apis": [
{ "name": "b2c", "source": { "kind": "command", "run": "dotnet run --no-build --project src/B2C -- export openapi.json", "output": "openapi.json" } },
{ "name": "admin", "source": { "kind": "command", "run": "dotnet run --no-build --project src/Admin -- export admin.json", "output": "admin.json" } }
]
}| Key | Meaning |
|---|---|
apis[].name |
unique name (letters, digits, ., _, -) |
apis[].source |
where the spec comes from at each ref, see below |
apis[].accept[] |
{ id, operation?, reason }; operation is METHOD /path as oasdiff reports it |
setup |
{ run, timeoutSeconds? }: a command run once per side, before any spec source of that side; see below |
concurrency |
2 (default) prepares the base and revision sides in parallel; 1 prepares them one after the other |
oasdiff.path |
oasdiff binary; a relative path is resolved against the repository root |
oasdiff.download |
false never downloads the pinned oasdiff (default true) |
oasdiff.args |
extra arguments for oasdiff changelog (not --format, -f, --fail-on, -o) |
Spec sources:
{ "kind": "file", "path": "api/openapi.yaml" }: a committed spec. A spec present only in the revision isapi-added(safe); one present only at the base isapi-removed(breaking).{ "kind": "command", "run": "...", "output": "openapi.json", "timeoutSeconds": 600 }: the command runs twice, once inside each materialised ref: a temporary checkout of the base commit and one of the revision commit (never your working tree). It runs through the shell with that checkout as the working directory and must writeoutput, relative to the checkout. The environment carriesCOMPAT_SIDE(baseorrevision),COMPAT_REFandCOMPAT_COMMIT. A committed copy ofoutputis deleted before the command runs, so a stale file never passes for a fresh export. The default timeout is 600 seconds (maximum 7200). Use it for specs generated from code (NSwag, Swashbuckle, FastEndpoints), and make sure the command works on a clean checkout (restore dependencies inside it).{ "kind": "url", "base": "https://dev.example.com/swagger.json", "revision": "https://..." }: fetched over HTTP(S), for example from a DEV environment.{ "kind": "serve", "run": "...", "url": "http://127.0.0.1:{port}/swagger/v1/swagger.json" }: for specs that exist only while the app runs (ASP.NET with Swashbuckle, NSwag or FastEndpoints, spec hidden in production). In each materialised ref the tool picks a free port, startsrunthrough the shell, pollsurluntil it answers 2xx with an OpenAPI document (JSON or YAML), saves it, and stops the app with everything it started (SIGTERM, then SIGKILL to the whole process group after 3 seconds). See below.
setup runs once in each materialised ref, whatever the number of APIs, before their spec sources, with the
same working directory and environment (COMPAT_SIDE, COMPAT_REF, COMPAT_COMMIT) as a command source. Use it
for a build that every export command shares; the export commands then skip the build (dotnet run --no-build).
The default timeout is 600 seconds (maximum 7200). A failing setup fails the layer with the side, the ref and the
end of its stderr. The base and revision checkouts are separate, so both sides (setup, then the spec of each API in
order) run in parallel; set "concurrency": 1 when one build at a time is all the machine can take.
A serve source for an ASP.NET API whose spec sits behind an internal key:
{
"setup": { "run": "dotnet build App.slnx -c Debug", "timeoutSeconds": 900 },
"apis": [
{
"name": "b2c",
"source": {
"kind": "serve",
"run": "dotnet run --no-build --no-launch-profile --project src/Api",
"url": "http://127.0.0.1:{port}/swagger/v1/swagger.json",
"ready": "http://127.0.0.1:{port}/hc",
"env": { "ASPNETCORE_URLS": "http://127.0.0.1:{port}", "ASPNETCORE_ENVIRONMENT": "Development" },
"headers": { "X-Internal-Api-Key": "${INTERNAL_API_KEY}" },
"timeoutSeconds": 180
}
}
]
}serve key |
Meaning |
|---|---|
run |
shell command that starts the app and keeps running; working directory is the materialised ref |
url |
where the running app serves the spec |
ready |
optional URL polled until 2xx before url, for example a health check |
env |
extra environment variables for the app |
headers |
sent with every request; ${VAR} reads the environment, a missing variable fails the layer, values are never printed |
timeoutSeconds |
from the start of the app until the spec is fetched; default 180, maximum 7200 |
{port} in run, url, ready, env and headers is replaced by a free port picked for each side, so the base
and revision apps run in parallel without clashing; the app also gets it as COMPAT_PORT, next to COMPAT_SIDE,
COMPAT_REF and COMPAT_COMMIT. The report names the spec by its url with {port} kept and any query redacted.
When the spec never arrives, the error names the last answer of the URL (HTTP 401, connection refused, ...)
and the end of the app output; an app that exits early is reported with its exit code. Each API with a serve
source starts its own app. When the repository has no committed spec, init proposes a disabled serve source for
every executable *.csproj (Sdk="Microsoft.NET.Sdk.Web" or <OutputType>Exe</OutputType>) that references
FastEndpoints.Swagger, NSwag.AspNetCore or Swashbuckle.AspNetCore and whose own C# files call
SwaggerDocument(, AddSwaggerGen(, AddOpenApiDocument( or MapOpenApi(. Class libraries and projects without
such a call are left out and named on stderr; when no project calls one (the registration sits in a shared
library), every executable project is kept. Two or more APIs listed by one *.sln/*.slnx get
"setup": { "run": "dotnet build <solution> -c Debug" } and dotnet run --no-build, so each side builds once.
oasdiff judges the contract, not what deployed clients do. This layer reads what the live builds of each client
call and send, and re-classifies the openapi findings of that client's API and the
enum-member-exposed-added findings of persisted-enums. It runs after both and needs at least one
of them enabled; it adds no findings of its own. What each live ref calls also scopes
error-codes returnedBy.
{
"clients": [
{
"name": "mobile",
"api": "b2c",
"refs": { "tags": "mobile-2.*", "since": "2.0.1" },
"generatedClient": { "kind": "typescript", "path": "APP/MOBILE/B2C/services/api/petseo.client.ts" },
"sources": ["APP/MOBILE/B2C/{app,components,services,hooks}/**/*.{ts,tsx}"]
}
]
}| Key | Meaning |
|---|---|
clients[].name |
unique name (letters, digits, ., _, -), shown in reasons as mobile@2.0.1 |
clients[].api |
the openapi API name this client calls |
clients[].refs |
the live client builds: a list of entries, or one selector on its own (see below) |
clients[].generatedClient |
{ kind: "typescript", path }: the generated client, read at every client ref |
clients[].sources |
optional globs of the client's own code; an operation then counts as called only when its client function is referenced there |
clients[].deployedWith |
optional "revision" for a web client deployed with the server: refs then only run in tabs opened before the deploy, so a finding every calling ref of which is such a client keeps its class and its message adds stale bundle only. The client is also read at the revision for the rollback view (below) |
An entry of refs is one of:
| Entry | Resolves to |
|---|---|
a git ref, e.g. "2.2.4" |
that ref |
a resolver: "github-deployment:<environment>", "github-workflow:<file>", "latest-tag[:<glob>]" |
one ref, as for --base; a web client deployed with the server is "github-deployment:prod" |
{ "tags": "<pattern>", "since"?: "<version>" } |
local tags matching the git tag --list pattern, at or above the since version |
{ "workflowRuns": "<file>", "since"?: "<version or YYYY-MM-DD>" } |
the head commit of every successful run of that workflow, labelled by the run's tag or branch (the newest run per label); since keeps labels at or above a version, or runs created on or after a date |
{ "easUpdates": { "run": "<command>", "timeoutSeconds"?: <n> } } |
the over-the-air updates the command prints, one commit<TAB>label[<TAB>dirty] line each: every distinct commit becomes the ref ota:<label> (see below) |
{ "ref": "<ref or resolver>" } |
the same as the plain string; use it to add optional |
Every object entry takes "optional": true: when it resolves to nothing (no successful run, no matching tag or
deployment, an easUpdates command that exits 0 and prints no line), the layer adds a note naming it instead of
failing, e.g. optional entry easUpdates resolved to nothing: the command exited 0 and printed no update. A resolver
that fails still fails the layer, optional or not: a GitHub API error, or a command that exits non-zero, times out or
prints a malformed line. A list where no entry resolves still fails.
For a mobile client built by a workflow on its tags, { "workflowRuns": "eas-prod.yml", "since": "2.0.1" } lists
exactly the shipped builds, even when server tags are interleaved with them. The GitHub entries use the token and
repository of the --base resolvers, and more than 1000 successful runs need a date since. A resolver that finds
nothing, or a resolved commit missing from the clone, fails the layer. The layer notes name what each resolver
resolved to, e.g. client "mobile": workflowRuns:eas-prod.yml since 2.0.1 → 2.0.1, 2.0.2, 2.1.1, 2.1.2, 2.2.4.
With an over-the-air channel (Expo EAS Update, CodePush), an installed build runs the newest update published to its
channel and runtime version, not the code of the build's commit. List the updates next to the builds, so a call kept
only by an OTA bundle keeps its class. The command runs in the repository root with the check's environment, so the
tool itself needs no Expo token, but the CI step does (EXPO_TOKEN). With Expo EAS Update, builds receive updates
through a channel, and the channel's branch mapping decides which branches it serves; the branch name matches the
channel name only by convention. eas channel:view <channel> --json carries that mapping as a JSON string
(currentPage.branchMapping, with data[].branchId), and eas branch:list --json lists each branch's live updates
with their gitCommitHash (eas update:list --json does not carry the commit). The example reads the mapping of the
production channel and lists the updates of every mapped branch, so a rollout between two branches lists both. In a
monorepo, cd into the Expo app first:
"refs": [
{ "workflowRuns": "eas-prod.yml", "since": "2.0.1" },
{
"easUpdates": {
"run": "cd apps/mobile && channel=$(eas channel:view production --json --non-interactive) && branches=$(eas branch:list --json --non-interactive) && ids=$(printf '%s' \"$channel\" | jq -c '.currentPage.branchMapping | fromjson | [.data[].branchId]') && updates=$(printf '%s' \"$branches\" | jq -r --argjson ids \"$ids\" '.[] | select(.id | IN($ids[])) | .updates[] | [.gitCommitHash, (.runtimeVersion + \"-\" + .platform), (if .isGitWorkingTreeDirty then \"dirty\" else \"\" end)] | @tsv') && if [ \"$ids\" = '[]' ]; then echo 'channel production maps no branch' >&2; elif [ -z \"$updates\" ]; then echo 'the branches of channel production have no update' >&2; else printf '%s\\n' \"$updates\"; fi"
},
"optional": true
}
]The command runs in sh, so a failing eas call has to stop it before jq, which would otherwise exit 0 on empty
input and hide the error: the example captures the JSON first and pipes it only after eas succeeded (in a bash
script, set -o pipefail does the same). When the command prints no update, the last line it wrote to stderr becomes
the reason in the note, so the two empty cases read differently: a channel that maps no branch (no OTA in production)
gives ... printed no update: channel production maps no branch, a mapped branch with no update yet gives
... printed no update: the branches of channel production have no update. A missing token or a changed JSON shape
fails the layer. Check the JSON fields against your eas-cli version.
An update marked dirty adds a note: its commit only approximates the bundle. Each OTA commit must be in the clone,
like any other client ref.
What changes, per openapi finding of the client's API that is not accepted and not safe, for an operation
(METHOD /path):
- No client ref calls the operation →
safe, reasonnot called by mobile@2.0.1, 2.1.1, 2.2.4. request-property-became-required,new-required-request-propertyorrequest-property-became-not-nullable, and every calling ref always sends the property (the typed body parameter is not optional and the property is declared without?, and also withoutnullfor the not-nullable rule) →safe, with the declarations as evidence.allOf[...]segments of the property path are skipped (a generated client flattens allOf into one type or an intersectionA & B, whose members the reader merges); underoneOf[...]oranyOf[...]the class stays. A client generated from the base spec declares the property as the base allowed it (prop?: T), so withsourcesthe layer also reads the call sites: every call of the client function must pass an object literal (directly, as a localconst, or through one parameter of the enclosing function: its callers, ormutate/mutateAsyncof the hook whosemutationFnit is) that sets the property to a value that cannot beundefinedornull(a literal, a conditional whose branches all qualify,a ?? bwithbqualifying, or a name ora.bmember whose declared type has neither; a typez.infer/z.output/z.input<typeof s>of a locals = z.object({ ... })counts, also throughs = base.superRefine(...)(orrefine,strict,strip,passthrough,describe) of such a schema, its members read from the schema: no.optional()/.nullable()/.nullish(), and forz.inputno.default()). Then the finding issafe, reasonalways sent non-null by the call sites of ..., with the literals as evidence. A spread, an argument or value the reader cannot follow, or the client function passed around uncalled keeps the class, and the message says where the reader stopped, e.g.(client-usage: cannot prove data.daysOfWeek non-null at app/pet/[id].tsx:187):no call of <function> in sources,<function> is used without a call at <file:line>,cannot follow the body argument at <file:line>,cannot follow <param> past <function> (<file:line>)orcannot prove <expr> non-null at <file:line>. That line is added as evidence, and the JSON report carriesreclassifyAttempt: { reason, stoppedAt }. The generated client is never read as client code, even when asourcesglob matches it (a layer note says so), and a method declared with a return type (name(...): AxiosPromise<T> {) is never read as a call.- Otherwise the class stays, the message names the refs that call the operation (or may omit the property), and the calls are added as evidence.
A re-classified finding keeps its rule id and shows reclassified from <class> by client-usage: <reason>; the JSON
report carries reclassified: { from, by, reason } and evidence with side: "client".
The TypeScript reader understands NSwag, swaggie, orval, axios or fetch style clients (a path literal plus
method: "POST", .post(...) or request("post", ...)) and openapi-typescript paths. Template holes, nested
template literals included (`/api/pets/${encodeURIComponent(`${petId}`)}`), read as path parameters. It fails
closed: a client ref missing from the clone (fetch tags, fetch-depth: 0), a generated client that is absent or
yields no operation, a URL string (const url = ..., url: ...) the reader could not turn into an operation, or
sources that match no file fail the layer and refine nothing. A path whose HTTP method cannot be read counts as
called with every method.
For a client with deployedWith: "revision", the layer also takes the rollback view. After a rollback the base
server serves every tab opened since the deploy, so the revision build of the client (its generated client and
sources read at the revision) must not send what the base server does not accept. A request widening oasdiff calls
safe going forward, which the revision build exercises, becomes rollback-risk (reason web@<revision> deploys with the server and calls it; the base server does not accept this after a rollback):
request-property-became-nullableandrequest-property-became-optional: unless the revision build always sends the property (non-null), proved by its type or call sites as above. The message adds that a non-nullable value type (number, boolean) at the base is bound to its default (0, false) instead of rejected, which saves the request with a wrong value and no error.request-body-became-optional/-nullable,request-parameter-became-optional/-nullable,request-parameter-property-became-nullableand therequest-*-enum-value-addedchecks: whenever the revision build calls the operation.
A revision build that cannot be read fails the layer like a live ref.
For an enum-member-exposed-added finding of an enum exposed through an API with clients, the layer scans the
sources of every live ref for code that branches on the exposed fields:
- No ref branches →
safe, reasonno live client ref branches on NotificationDto.type: mobile@2.2.4. - A ref branches → the class stays, the message names the refs, and the branch sites are added as evidence.
- A client without
sources, or an exposing API with no client, cannot prove the absence of a branch: the class stays and the message says why.
A branch is a switch over the property (the last segment of the field, compared case-insensitively) or the enum,
an equality (===, !==, ==, !=) with an operand ending in the property or naming the enum, case Enum.X,
Record<Enum, ...> or Record<Dto["property"], ...>, [key in Enum], or an index access map[x.property]. A
line that looks like a branch and holds the property or enum name where the scanner read no code (inside a template
literal, after a literal it lost) counts as a branch too, so a scanner miss never reads as "does not branch".
Common property names (type, status) also appear on unrelated objects, so a comparison with a string literal
that names no member of the enum (by name or string value, case-insensitively, at either ref) is not a branch:
event.type === "set" is ignored, n.type === "TermsChange" counts. The same holds for a switch over the
property whose case labels are all such literals. A string literal is never read as the property itself
(key === "content-type"). Comparisons with identifiers, template literals with holes, or expressions
("a" + b) still count.
A server that returns typed error codes (new Error("Shop.Cart.NotFound", ...)) that each client translates in its
own map can break old clients without any contract change: the spec types the code as a string, so openapi cannot
see a new one, and an old mobile build shows a generic error instead of the message. This layer reads the codes at
both refs and each client's translation map at the client's live refs.
{
"codes": [{ "name": "api", "files": ["src/**/*.cs"], "pattern": "new Error\\(\\s*\"(?<code>[\\w.]+)\"" }],
"clients": [
{
"name": "mobile",
"refs": { "workflowRuns": "eas-prod.yml", "since": "2.0.1" },
"files": ["APP/MOBILE/B2C/constants/api.ts"],
"pattern": "\"(?<code>[\\w.]+)\"\\s*:"
}
],
"returnedBy": [{ "codes": "ServiceVendor.Location.*", "api": "b2b", "operations": ["POST /api/service-vendors"] }],
"accept": [{ "code": "Shop.*", "client": "mobile", "reason": "the shop is web only" }]
}| Key | Meaning |
|---|---|
codes[] |
{ kind?: "regex", name, files, pattern, flags?, report? }: server files read at the base and the revision; the named group code of pattern captures one code; report: false for a source that only feeds a composed one |
codes[] |
{ kind: "composed", name, template, parts }: codes built at runtime, see below |
clients[] |
{ name, refs, files, pattern, flags? }: the client's translation map files, read at every live ref; the named group code captures one translated code |
clients[].refs |
the live client builds, exactly as client-usage refs (refs, resolvers, tags, workflowRuns and easUpdates selectors, optional) |
flags |
regex flags out of i, m, s, u |
clients[].usage |
the client-usage clients whose calls are this client's, for returnedBy; defaults to the one with the same name |
clients[].deployedWith |
optional "revision" for a web client deployed with the server: its map files are also read at the revision, and a new code the revision build translates is safe, reason only web@2.2.4 tabs opened before the deploy; web@<revision> translates it |
returnedBy[] |
{ codes, api, operations }: codes (or globs such as Shop.*) that only these operations of the openapi API return; operations are METHOD /path/glob, the method may be * (* /api/shop/**); both take one string or a list |
accept[] |
{ code, client?, reason }: accepts error-code-unknown-to-client for that code (and client); code may be a glob such as Shop.* |
| Class | Finding ids |
|---|---|
safe |
error-code-added, error-code-removed |
needs-action |
error-code-unknown-to-client: a code new in the revision that at least one live ref of the client does not translate; the message names those refs and the evidence points at the declaration and their map files |
A code the base already returns is live today, so only codes new in the revision count against clients, and a client whose every live ref already translates a new code gets no finding. The layer fails closed: a code source that matches no file or captures no code at the revision, a client ref missing from the clone, or a map file that is absent or yields no code at a client ref fails the layer.
A code counts for every client unless a returnedBy entry covers it. With one, and with client-usage enabled, the
finding becomes safe for a client whose live refs call none of the matching operations, or call them only as
operations openapi reports as endpoint-added; the reason names the operations and refs, for example
returned only by POST /api/service-vendors (b2b), which mobile@2.0.1 does not call. A client's calls are what its
client-usage clients' generated clients call, so an API none of them targets counts as not called. A client
without client-usage calls keeps its findings, and a note says so. Every accept entry is noted with the number of
findings it accepted, or as unused when it matched none; a finding returnedBy already made safe is not counted,
so an entry it made redundant reads as unused.
Some codes are built at runtime, for example a generic RepositoryErrors<TEntity>.NotFound declared as
$"{typeof(TEntity).Name}Repository.NotFound". A composed source builds those codes from what regex sources
capture, and they count like literal ones:
{
"codes": [
{ "name": "literal", "files": ["src/**/*.cs"], "pattern": "static readonly Error \\w+ = new\\(\\s*\"(?<code>[\\w.]+)\"" },
{ "kind": "regex", "name": "repository-entities", "files": ["src/**/*.cs"], "pattern": "RepositoryErrors<\\s*(?<code>(?!TEntity)\\w+)\\s*>", "report": false },
{ "kind": "composed", "name": "repository-not-found", "template": "{entity}Repository.NotFound", "parts": { "entity": "repository-entities" } }
]
}Every {part} of the template names a regex code source in parts; the source gives one code per combination of
the codes its parts capture at that ref, and fails above 1000 of them. Its evidence points at the usage that supplied
the last part in template order (RepositoryErrors<FeatureFlag>). A report: false source gives no finding and may
capture nothing at a ref; the composed source fails when it builds no code at the revision.
Classifies the migrations that exist in the revision but not in the base: the ones the deploy will run. A migration
that the base already had and the revision edits is migration-modified, one the revision deletes is
migration-removed (both needs-action: production already ran the old version).
{
"sources": [
{ "name": "app", "dialect": "postgres", "kind": "folder", "path": "drizzle", "include": ["**/*.sql"] },
{
"name": "legacy",
"dialect": "sqlserver",
"kind": "ef-script",
"path": "src/Db/Scripts/migrations.sql",
"historyTable": "__EFMigrationsHistory",
"preconditions": { "run": "scripts/prod-max-ids.sh", "timeoutSeconds": 60 },
"writers": [{ "table": "dictionaries.PetBreeds", "files": ["src/**/*.cs"], "patterns": ["new PetBreed(", "INSERT INTO dictionaries.\"PetBreeds\""] }],
"accept": [
{ "id": "update-data", "migration": "20261005073152_AddMissingPetBreeds", "object": "Breeds", "reason": "backfill, old builds ignore the column" },
{ "id": "insert-explicit-id", "migration": "20261005073152_AddMissingPetBreeds", "object": "dictionaries.PetBreeds", "basis": "migrations-only", "reason": "dictionary rows, only migrations write them" }
]
}
]
}| Key | Meaning |
|---|---|
name |
unique source name |
dialect |
postgres or sqlserver |
kind: "folder" |
every file under path matching include (default ["**/*.sql"]) is one migration, identified by its path relative to path |
kind: "ef-script" |
path is an EF Core idempotent script (dotnet ef migrations script --idempotent); each MigrationId guard block is one migration. historyTable defaults to __EFMigrationsHistory |
accept[] |
{ id, migration, object?, basis?, reason }; migration is the EF migration id or the file path relative to the folder; object is table or table.column as the report shows it; basis: "migrations-only" (only for insert-explicit-id, below) |
preconditions |
optional { run, timeoutSeconds? }: a command that answers insert-explicit-id with production max(id) (below) |
writers[] |
optional { table, files, patterns }: base files matching the files globs that contain any of patterns (plain text, case-sensitive) write table outside the migrations (below) |
Rules and classes:
| Class | Rule ids |
|---|---|
safe |
create-schema, create-table, add-column, create-index |
needs-action |
add-unique-index, add-constraint, drop-default, update-data, delete-data, merge-data, insert-explicit-id, object-redefined, migration-modified, migration-removed |
rollback-risk |
drop-not-null, enum-value-added |
breaking |
add-required-column, drop-table, drop-column, drop-object, rename-table, rename-column, move-table, change-column-type, alter-column, set-not-null, enum-value-renamed, truncate |
Statements on a table created by the same set of new migrations are safe. insert-explicit-id reports the id
range and the precondition (for example "production max(Id) < 418"). When the same migration later moves the
table's identity sequence past those ids (setval(...) with MAX(...) or a higher literal, ALTER SEQUENCE ... RESTART WITH n, ALTER TABLE ... ALTER COLUMN ... RESTART WITH n), the sequence half of the precondition is
dropped and that statement is added as evidence.
preconditions settles that precondition with a command you own, so nobody runs the query by hand or keeps a
stale number in an accept reason. It runs once per source, in the repository's working directory (not a
checked-out ref), and only when an insert-explicit-id finding with integer ids exists. COMPAT_TABLES holds the
tables, one per line as the report shows them (dictionaries.PetBreeds); the command prints table<TAB>maxId
lines, an empty or NULL max meaning an empty table. Nothing else from the output is kept or printed.
#!/usr/bin/env bash
# scripts/prod-max-ids.sh
while read -r table; do
printf '%s\t%s\n' "$table" "$(psql "$PROD_READONLY_URL" -At -c "SELECT max(\"Id\") FROM $table")"
done <<< "$COMPAT_TABLES"A max below the first inserted id adds production max(Id) = 417 < 418 (preconditions command) and turns the
finding safe when the migration also moves the identity sequence (otherwise it stays needs-action for the
sequence half); a max at or above it turns the finding breaking (the insert collides). A table missing from the
output keeps its class with a note; a failing command adds a note and changes nothing. timeoutSeconds defaults
to 60.
When production is out of reach, the base often makes the precondition very likely by itself. Every
insert-explicit-id that the preconditions command did not settle also says what the base's migrations (the EF
script's statements outside the guards included) wrote into the table, with the base line as evidence: the base migrations insert explicit Id up to 417 into dictionaries.PetBreeds, and never move its identity sequence (or move its identity sequence, insert no explicit ids, insert explicit ids ... that cannot be read). With a writers
entry for the table it adds whether base code outside the migrations writes it: no base code outside the migrations matches the writers of dictionaries.PetBreeds, or base code outside the migrations writes dictionaries.PetBreeds (src/Admin/BreedsController.cs:42) with that line as evidence. The base's migration files never
count as writers, so files may be as wide as **/*.
The finding stays needs-action. An accept entry with "basis": "migrations-only" accepts it only while that
evidence holds: a writers entry is configured for the table, no base file matches it, the base migrations insert
explicit integer ids into the table, and the highest of them is below the first new id. Once a writer appears or the
ids move, the entry accepts nothing and a note says why (its basis migrations-only does not hold: base code writes dictionaries.PetBreeds (src/Admin/BreedsController.cs:42)), so the accept cannot outlive its reason. A finding the
preconditions command turned breaking is never accepted by such an entry.
Seed scripts that run on every deploy are compared row by row: per table and row key, across all files of a source.
The row key is the ON CONFLICT (...) target, else the MERGE ... ON pairs, else the first column.
{
"sources": [
{
"name": "db",
"dialect": "postgres",
"files": ["db/seed.sql", "db/seed/*.sql"],
"accept": [{ "id": "row-changed", "object": "EmailTemplates", "reason": "copy fix, approved" }]
}
]
}| Key | Meaning |
|---|---|
name, dialect |
as for sql-migrations |
files |
globs of the seed scripts; a source whose globs match no file in the revision fails the layer |
accept[] |
{ id, object?, reason }; object is the table as the report shows it, or the file path for seed-file-removed |
| Class | Finding ids |
|---|---|
safe |
row-added, row-removed, seed-file-removed, insert-query-added |
needs-action |
row-changed, row-change-ignored, row-added-skipped, row-deleted, insert-unguarded, upsert-query, update-data, delete-data, truncate, unreadable-write |
Dynamic SQL with a literal body (EXEC(N'...'), EXEC sp_executesql N'...', EXECUTE '...' inside a DO body)
is unwrapped and its statements are compared like any other, with the lines of the outer file. Dynamic SQL without a
literal body (EXEC(@sql), EXECUTE format(...), concatenation), COPY ... FROM and BULK INSERT are reported as
unreadable-write. psql's \copy is a client command and is not read.
When persisted-enums runs too, a row-added or row-changed finding whose rows write a string
literal equal to a member that persisted-enums reports as enum-member-added (string storage) becomes
rollback-risk, with the enum declaration as evidence: a base build that reads the table fails on those rows after a
rollback. When the rows write one such member, the finding carries topic (NotificationType.TermsChange) and the
Markdown report shows it together with the member's finding.
Enum members stored in the database must stay readable by both builds. The layer parses C# and TypeScript enums
(.cs, .ts, .tsx, .mts, .cts) at both refs.
{
"sources": ["src/**/*.cs"],
"enums": [
{ "kind": "named", "name": "OrderStatus", "storage": "int", "file": "src/Domain/OrderStatus.cs" },
{ "kind": "discover", "files": "**/*DbContext.cs", "pattern": "ConfigureEnum<(?<name>[\\w.]+)>", "storage": "string" }
],
"accept": [{ "id": "enum-member-added", "enum": "NotificationType", "member": "TermsChange", "reason": "no rollback below 2.3.4" }]
}| Key | Meaning |
|---|---|
sources |
glob or globs of the files that declare enums |
enums[] named |
an enum by name; file pins the declaration when several files declare that name; exposed lists where clients receive it (below) |
enums[] discover |
every enum whose name the regex pattern captures in files (named group name, else the first group) |
storage |
string (stored by name or string value) or int (stored by number) |
accept[] |
{ id, enum, member?, reason } |
| Finding id | Class |
|---|---|
enum-member-added |
rollback-risk: once a row holds it, the base build cannot read that row; when a seed row writes it (string storage), the message says so and the seed row is evidence |
enum-member-removed |
breaking |
enum-member-renamed |
breaking for string storage; needs-action for int storage (the number is unchanged) |
enum-member-renumbered |
breaking (int storage: existing rows change meaning) |
enum-member-unresolved |
needs-action: the stored number cannot be computed without a compiler |
enum-added |
safe |
enum-removed |
needs-action |
enum-member-exposed-added |
needs-action, only for an enum with exposed (below) |
Many APIs send an enum to clients as a plain string DTO field, so the spec has no enum list and openapi cannot
see a new value. A named entry declares those fields:
{
"kind": "named",
"name": "NotificationType",
"storage": "string",
"exposed": [{ "api": "b2c", "fields": ["NotificationDto.type"] }]
}api is the API name used by openapi and client-usage; each field is Type.property. Every added member then
also gets enum-member-exposed-added (needs-action: old clients receive an unknown value in that field), with the
same subject and evidence as enum-member-added. It can be accepted like any other finding, and
client-usage re-classifies it by whether live client builds branch on the field.
Reports configuration keys the revision needs that production may not have.
{
"sources": [
{ "kind": "compose" },
{ "kind": "dotenv", "valuesAreDefaults": true },
{
"kind": "regex",
"name": "dotnet-required",
"files": ["src/**/*Settings.cs"],
"pattern": "public required [\\w<>?]+ (?<member>\\w+) \\{",
"enclosing": "class (?<section>\\w+?)Settings\\b",
"key": "{section}__{member}",
"comments": "slash"
}
],
"accept": [{ "key": "SHOP_API_KEY", "id": "config-key-added-required", "reason": "set in the production vault" }]
}| Source | Reads |
|---|---|
compose |
${VAR} interpolation in compose files (default files: **/{docker-compose,compose}{,.*}.{yml,yaml}); ${VAR:-x} has a default, ${VAR} and ${VAR:?msg} are required. Block scalars (|, >) and multi-line quoted scalars are read, a # inside them is text. Pass-through environment entries (- KEY, [KEY], KEY:, KEY: ~, also through *alias and <<: *alias) are required: the host supplies the value |
dotenv |
keys of .env examples (default files: **/.env.{example,sample,template,dist}, **/{example,sample}.env); with valuesAreDefaults: false (the default) every key is required, with true a key with a value has a default |
regex |
your own pattern over files: named group key and an optional default; flags from i, m, s, u; comments none, hash or slash blanks comments first. key builds the key from several named groups instead, e.g. "{section}__{member}"; a group can also come from enclosing, a pattern whose nearest match before the key lends its groups (the settings class around a member). A placeholder nothing fills stays empty |
appsettings |
.NET appsettings*.json files (comments and trailing commas allowed); each leaf is a key named by its configuration path (Stripe:SecretKey, arrays by index: Stripe:Plans:0:ProductId). A value matching placeholder (case-insensitive regex, default placeholder|set-via-env|changeme|^$) or null has no default, so a real value replaced by a placeholder is config-key-default-removed and a new placeholder key config-key-added-required; other values are defaults. environment (e.g. "Production") layers the sibling appsettings.{environment}.json over each file. service names the compose service that runs the app: a key without a default that its environment sets (Stripe__SecretKey=...) is safe, with the compose line as evidence, and one it does not set names the service; the service must exist in composeFiles in the revision. composeFiles defaults to the files of the layer's compose sources, so a local-dev compose with the same service name is not read as the deploy; only without a compose source does it default to every compose file (**/{docker-compose,compose}{,.*}.{yml,yaml}). When the service is in more than one of those files, their entries are merged (the first file in listing order wins per key) and a note names the files |
Upgrading from 0.2.x: the compose source now also reads block scalars, multi-line quoted scalars and pass-through
environment entries, so a check that passed before may report keys it missed; record intended ones in accept.
Every source has an optional unique name (compose and dotenv default to their kind; regex and appsettings require one)
and an optional prefix prepended to each of its keys, for a source that reads one section only ("prefix": "Shop__").
Keys are normalized before they are compared ("keyMatching": "normalized", the default): they are split on
:, __, ., _, - and case changes and joined in upper snake case, so Shop:BaseUrl, Shop__BaseUrl,
SHOP_BASE_URL, shop.base_url and the .NET member ShopBaseUrl are one key, SHOP_BASE_URL. A finding names
the normalized key, every source that reads it, and the original spellings when they differ.
"keyMatching": "exact" compares keys as written. accept[].key may use any spelling.
Keys are compared file by file for files present at both refs. A source fails the layer when it matches no file,
loses its files or all its keys in the revision, or (dotenv and regex) finds no key. Default values are never printed,
except for watched keys.
accept[] entries are { key, id, reason }.
watch[] entries are { key, class, reason? } for operational keys whose value changes how production runs, such as
consumer throughput or a retry policy. A watched key that is added, removed or whose default changed gets at least
class instead of safe, and the finding prints reason and the defaults at both refs
(default absent → "1"), so a hard-coded value moved into configuration with a different value is visible. A watched
key is declared non-secret by listing it. key may use any spelling and is normalized like other keys; * matches
any run of characters and ? one character.
"watch": [{ "key": "Consumers:*", "class": "needs-action", "reason": "consumer throughput and retry policy" }]chains compares sources with each other in the revision: in a chain, every key present in one source must be
present in all the others (matched after normalization), so a key added to deploy-dev but not to deploy-prod,
or to the compose file but not to the Ansible assert, is reported as config-chain-missing.
"chains": [
{ "name": "app-env", "sources": ["compose", "ansible-template", "deploy-prod"], "required": ["ansible-assert"] },
{ "name": "dev-prod-parity", "sources": ["deploy-dev", "deploy-prod"] }
],
"accept": [{ "key": "DEBUG_TOOLBAR", "chain": "dev-prod-parity", "reason": "DEV only" }]| Chain field | Meaning |
|---|---|
name |
unique chain name, shown as the finding scope chain <name> |
sources |
names of sources in the chain; together with required at least two |
required |
sources whose missing key is breaking (e.g. the assert that guards the deploy); a miss elsewhere is needs-action |
class |
overrides the class of every finding of the chain |
scope |
changed (default): only keys whose declarations differ between the refs in some source of the chain (added, removed, default changed); all: every key, for an audit |
A chain with a source that failed to scan is skipped with a note. Chain accept[] entries are
{ key, chain, reason } for an intentional asymmetry.
presence (optional) resolves the keys that need a value in production by asking the target environment for its
key names, never its values. run is a shell command that prints the names, one per line; timeoutSeconds
defaults to 60.
"presence": { "run": "gh secret list --env prod --json name --jq '.[].name'", "timeoutSeconds": 60 }Other stores work the same way: op environment read <id> | cut -d= -f1, doppler secrets --only-names,
aws ssm get-parameters-by-path --path /prod --query 'Parameters[].Name' --output text | tr '\t' '\n'.
The command runs once, in the repository's working directory (not a checked-out ref), and only when a
config-key-added-required or config-key-default-removed finding exists. Names are normalized like every
other key. A listed key turns the finding safe ("present in the target environment"); a missing key stays
needs-action and says so. A line KEY=value is cut to KEY before anything is kept: values are never logged,
stored or written to the report, and errors never quote the command's output. A failing command adds a note and
leaves the findings as they were.
| Finding id | Class |
|---|---|
config-key-added-required |
needs-action: the value must exist in production before the deploy |
config-key-default-removed |
needs-action |
config-key-added-optional |
safe |
config-key-default-changed |
safe |
config-key-removed |
safe |
config-chain-missing |
breaking when a required source misses the key, else needs-action; class overrides |
Reports runtime package upgrades: a library upgrade can change behaviour both builds rely on without any contract change (a new retry policy in a messaging client, a new default in an ORM).
{
"sources": [{ "kind": "nuget" }, { "kind": "npm", "sections": ["dependencies"] }],
"watch": [
{
"name": "SOFTURE.*",
"class": "needs-action",
"releaseNotes": "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/SOFTURE/MessageBroker/releases"
}
],
"ignore": ["Microsoft.CodeAnalysis.*", "*.Analyzers", "xunit*", "Microsoft.NET.Test.Sdk"],
"accept": [{ "id": "dependency-upgraded", "name": "Npgsql", "from": "8.0.3", "to": "9.0.2", "reason": "release notes reviewed, no behaviour change" }]
}| Source | Reads |
|---|---|
nuget |
MSBuild files (default files: **/*.{csproj,fsproj,vbproj,props,targets}): PackageVersion (central package management), PackageReference and GlobalPackageReference with Include or Update and a version (VersionOverride, Version attribute or element); $(Property) is resolved as MSBuild sees it (see below); a reference without a version takes it from Directory.Packages.props; lockfile: packages.lock.json (versions 1 and 2) in the folder of a matched file |
npm |
package.json (default files: **/package.json); sections from dependencies (default), devDependencies, peerDependencies, optionalDependencies; lockfile: the nearest package-lock.json (versions 1 to 3) or pnpm-lock.yaml (5.x, 6.x, 9.x) in the manifest folder or above that installs it (workspaces), package-lock.json first |
A NuGet $(Property) resolves from the nearest Directory.Build.props, the nearest Directory.Packages.props, the
file itself with its <Import>s in document order and the nearest Directory.Build.targets; the last definition wins,
as in MSBuild. Only the nearest Directory.Build.props is read, as MSBuild does; a parent one counts when the child
imports it ($([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))). Import
paths may be relative or start with $(MSBuildThisFileDirectory) or $(MSBuildProjectDirectory). An import with
another property or a wildcard in its path, outside the repository, or missing without a Condition is not followed
and is named in the finding of a version that stays unresolved. Properties are expanded where they are defined and
properties inside a <Target> are ignored, as in MSBuild. Of Condition, only '$(Name)' == '' and != '' are
decided; a property that other conditions give different values (per target framework, per configuration) stays
unresolved and its finding lists the values. Versions that keep an undefined property are listed in the layer notes.
sources defaults to both kinds; files under node_modules, bin and obj are skipped. Packages are compared by
name over all files of a ref (NuGet names case-insensitively). With a lockfile, a direct dependency compares by the
version the lockfile resolves, so npm update that moves ^4.1.0 from 4.1.0 to 4.9.0 in the lockfile only is
reported, and a watch package installed only as a dependency of another one is reported when its version changes
(the message says resolved from lockfile or transitive, resolved from lockfile; evidence points at the
lockfile entry). Other transitive packages are not read. When a lockfile resolves a package at a ref, its declared
versions at that ref are not compared. Without a lockfile, or with lockfiles: false on a source, a range compares
by its lower bound (^1.2.3, [1.2,2.0)). When projects declare several versions of one package, a version that went down anywhere is a
downgrade, otherwise the jump from the lowest base version to the highest revision version decides the class. The
layer fails when no dependency file matches at either ref, a package.json is not valid JSON, or a lockfile cannot
be read or has a version this list does not support (set lockfiles: false on the source to skip lockfiles).
watch[] entries are { name, class?, releaseNotes? }: every finding of a matching package gets at least class,
and releaseNotes is printed with it (nothing is fetched). ignore[] lists packages that produce no finding.
Names in both are globs (*, ?, {a,b}) matched case-insensitively. accept[] entries are
{ id, name, from?, to?, reason }. With from and/or to an entry accepts only that transition ("from": "0.4.0", "to": "1.2.0"; ^1.2.0 in a manifest matches 1.2.0), so a reason written for one reviewed upgrade is not reused
for the next one: when the versions moved on, the finding stays unaccepted and a note names the new transition. An
entry without versions that accepts a needs-action upgrade gets a note asking to add them.
| Finding id | Class |
|---|---|
dependency-upgraded |
safe for a patch or minor upgrade; needs-action for a major upgrade, a minor upgrade below 1.0 or any upgrade below 0.1 |
dependency-downgraded |
needs-action |
dependency-changed |
needs-action: the declared version is not a version number at one ref (latest, a git URL, an unresolved $(Property), named with the imports that were not followed) |
dependency-added |
safe |
dependency-removed |
safe |
A release that starts calling a new endpoint of a third-party API with a credential production already has changes no contract of ours and adds no configuration key, yet it fails in production when the key does not allow that API (an API not enabled for the key, key restrictions, quotas, egress or firewall rules). This layer reads the outbound targets you declare at both refs and reports the ones new in the revision, so someone checks production before the deploy. It does not prove that the call works.
{
"targets": [
{
"name": "google-maps",
"files": ["src/**/Google*.cs"],
"pattern": "GetStringAsync\\(\\$?\"(?<path>[^\"]*)\"",
"host": "maps.googleapis.com/maps/api"
},
{ "name": "absolute", "files": ["src/**/*.cs"], "pattern": "\"(?<host>https://[\\w.-]+)(?<path>/[^\"?{]*)" }
],
"accept": [{ "target": "maps.googleapis.com/maps/api/geocode/**", "reason": "Geocoding API enabled on the prod key" }]
}| Key | Meaning |
|---|---|
targets[] |
{ name, files, pattern, flags?, host?, keyFrom?, service?, composeFiles? }: files read at the base and the revision; the named groups host and path of pattern capture one target, and the pattern needs at least one of them |
targets[].host |
host and base path joined before a captured path when the match captures no host, for example the BaseAddress of a typed HttpClient; it is a base with a trailing slash, so ../geocode/json against maps.googleapis.com/maps/api/place reads as maps.googleapis.com/maps/api/geocode/json; a path that is an absolute URL is used as it is |
flags |
regex flags out of i, m, s, u |
targets[].keyFrom |
appsettings: each capture's configuration key is the .NET path of the JSON leaf that holds it (Shop:BaseUrl); a named group key in pattern gives the key instead, for example the variable of GetEnvironmentVariable("CRM_URL") ?? "https://...". Requires service |
targets[].service |
the compose service that runs the app; a target whose key its environment sets is reported as that key (see below). Requires keyFrom or a key group |
targets[].composeFiles |
where service is looked up. Default: the files of the config layer's compose sources, so a local-dev compose that sets the same key never counts as the deploy; only without such a source every compose file (**/{docker-compose,compose}{,.*}.{yml,yaml}). The service must be in one of them in the revision |
accept[] |
{ target, reason }: accepts outbound-added for that target; target may be a glob such as maps.googleapis.com/maps/api/geocode/** (* stays within one path segment), or the key of a target the deploy sets (Shop:BaseUrl) |
A target is compared as host/path: without the scheme, the query string and the fragment, with a lower-case host
and no trailing slash, and with . and .. segments resolved as RFC 3986 does, so
https://Maps.googleapis.com/maps/api/place/../geocode/json?address={address} reads as
maps.googleapis.com/maps/api/geocode/json. Targets are compared across all sources, so a call moved from one file or
source to another gives no finding.
A URL in appsettings.json is often only the default the deploy replaces: with Shop__BaseUrl=${SHOP_BASE_URL} in
the compose environment of the app, production calls whatever the secret holds, never the host in the file. A source
with service reads that compose environment at each ref (Shop__BaseUrl sets Shop:BaseUrl, case-insensitive, as
.NET reads it), and a target whose key the service sets is compared and reported as the key, not as the file's host:
{
"targets": [
{
"name": "appsettings-urls",
"files": ["src/**/appsettings.json"],
"pattern": "\"BaseUrl\"\\s*:\\s*\"(?<host>https?://[^\"/]+)",
"keyFrom": "appsettings",
"service": "api",
"composeFiles": ["deploy/docker-compose.yml"]
}
]
}That gives outbound-added Shop:BaseUrl with the message outbound call through config key Shop:BaseUrl (SHOP_BASE_URL at deploy via Shop__BaseUrl of compose service api; file default api-shop-dev.example.com) is new in the revision, and the file line and the compose entry as evidence. A new file default under a key the deploy sets gives
no finding, since production does not call it. The finding's topic is the variable the compose value interpolates
(SHOP_BASE_URL), or the key in upper snake case, so the Markdown report shows it in one entry with the config
finding of the same key. Only the compose service is read; a key set elsewhere in the deploy (an Ansible role, a
vault) is not seen, and its target stays the file's host.
| Class | Finding ids |
|---|---|
safe |
outbound-removed: a target called at the base and no longer at the revision |
needs-action |
outbound-added: a target the revision calls and the base does not; the evidence points at its first capture |
The layer fails closed and then reports no finding: a source that matches no file or captures no target at the
revision fails the layer, and so does a target whose .. segments climb above its host. Every accept entry is noted with the number of findings it accepted, or as unused when it
matched none.
Messages that wait in a broker queue during a deploy are produced by one build and consumed by the other. The layer parses the C# contracts of both refs at source level (no build) and compares them the way MassTransit with System.Text.Json reads them, and compares the queue names your patterns find.
{
"sources": [
{ "name": "internal", "language": "csharp", "files": "src/PETSEO.Contract.Internal.Messages/**/*.cs" }
],
"queues": [
{ "kind": "regex", "name": "consumers", "files": "**/ConsumerGroups.cs", "pattern": "\"(?<queue>PETSEO\\.[\\w.]+)\"" }
],
"accept": [{ "id": "message-property-added", "subject": "PETSEO.Messages.OrderPlaced.Total", "reason": "consumers deployed first" }]
}| Key | Meaning |
|---|---|
sources[] |
{ name, language: "csharp", files, enumStorage? }; enumStorage is string (default, MassTransit writes enum names) or int |
queues[] |
{ kind: "regex", name, files, pattern, flags?, comments?, report? }: named group queue, else the first group; flags from i, m, s, u; comments slash (default), hash or none; report: false for a source that only feeds a composed one |
queues[] |
{ kind: "composed", name, template, parts }: names built at runtime, see below |
consumers[] |
{ kind: "regex", name, files, pattern, queue | queueFrom, select?, exclude?, flags?, comments? }: which queue consumes which message, see below |
accept[] |
{ id, subject, reason }, matching the finding subject exactly |
Some brokers build queue names at runtime, for example SOFTURE.MessageBroker.Rabbit 1.x names a consumer group
queue {Rabbit:Name}{GroupSeparator}{group}. A composed source builds those names from what regex sources find
and compares them like any other queue source:
{
"queues": [
{ "kind": "regex", "name": "rabbit-endpoint", "files": "**/appsettings.json", "pattern": "\"Name\":\\s*\"(?<queue>[^\"]+)\"", "report": false },
{ "kind": "regex", "name": "group-separator", "files": "**/appsettings.json", "pattern": "\"GroupSeparator\":\\s*\"(?<queue>[^\"]+)\"", "report": false },
{ "kind": "regex", "name": "consumer-groups", "files": "**/ConsumerGroups.cs", "pattern": "const string \\w+ = \"(?<queue>\\w+)\"", "report": false },
{
"kind": "composed",
"name": "group-queues",
"template": "{endpoint}{separator}{group}",
"parts": {
"endpoint": "rabbit-endpoint",
"group": "consumer-groups",
"separator": { "source": "group-separator", "default": "." }
}
}
]
}Every {part} of the template has an entry in parts: the name of a regex queue source, or { source?, default? }.
A part takes every name its source finds at that ref, its default when the source finds none, or only default
without a source. The source gives one queue per combination of part values, and fails above 1000 of them. Its
evidence points at the last part, in template order, that came from a source. A report: false source gives no
finding and may find nothing at a ref; the composed source fails when it builds no name at either ref. A separator
that only lives inside a library (not in your configuration) is a default, so a library upgrade that changes it is
reported by dependencies, not here.
Queue names alone do not say which queue consumes which message. When a release moves the consumer of an existing
message to another receive endpoint (a new consumer group, a renamed endpoint), every name may look compatible, yet
the messages already in the old queue have no consumer after the deploy. consumers declares the mapping, and the
layer compares it at both refs:
{
"consumers": [
{ "kind": "regex", "name": "broadcast-group", "files": "src/PETSEO.Worker.Sync/Consumers/**/TermsChange/*.cs",
"pattern": "IConsumer<(?<message>\\w+)>", "queue": "PETSEO.Worker.Sync.Broadcast" },
{ "kind": "regex", "name": "default", "files": "src/PETSEO.Worker.Sync/Consumers/**/*.cs",
"pattern": "IConsumer<(?<message>\\w+)>", "queue": "PETSEO.Worker.Sync", "exclude": ["broadcast-group"] }
]
}Each source reads its files (without the files of the sources named in exclude) at both refs, and every type its
pattern captures in the named group message is consumed from its queue; a namespace or outer type in the capture
is dropped, and comments (slash by default) are stripped first. Only messages declared in sources at both refs
are compared: a new or removed message is reported as such. For each queue that consumed a message at the base and
does not in the revision:
| Finding id | Class |
|---|---|
message-consumer-moved |
needs-action: the revision consumes the message from a queue the base did not; drain the old queue first, or keep the old endpoint for one release (after a rollback, the base build does not consume the new queue) |
message-consumer-removed |
needs-action: the revision consumes the message from no new queue, so what waits in the old one gets no consumer |
A consumer source fails the layer, without consumer findings, when it matches no file or captures no consumer in the revision.
A literal queue is the same at both refs, so a release that renames the receive endpoint (Rabbit:Name) moves no
consumer. queueFrom instead takes the queue from a source of queues, resolved at each ref; set exactly one of
queue and queueFrom:
{
"consumers": [
{ "kind": "regex", "name": "broadcast-group", "files": "src/PETSEO.Worker.Sync/Consumers/**/TermsChange/*.cs",
"pattern": "IConsumer<(?<message>\\w+)>", "queueFrom": "group-queues", "select": "Broadcast" },
{ "kind": "regex", "name": "default-endpoint", "files": "src/PETSEO.Worker.Sync/Consumers/**/*.cs",
"pattern": "IConsumer<(?<message>\\w+)>", "queueFrom": "rabbit-endpoint", "exclude": ["broadcast-group"] }
]
}queueFrom names a regex or composed queue source (report: false ones too). Without select the source must
give exactly one name at the ref. select keeps the names of which a part value equals it exactly: for a composed
source the value of any {part} (here the group part Broadcast picks PETSEO.Worker.Sync.Broadcast), for a
regex source the whole name. When the source, after select, gives no name or several at a ref, or the source itself
failed, the layer fails without consumer findings and names the consumer source, the queue source and the ref. With
queueFrom, an endpoint rename reports every consumer of an existing message as message-consumer-moved.
What counts: every public, non-static class, record, record struct, struct and interface (nested ones too),
identified by its full name (Namespace.Outer+Inner, generic arity as `1) or its [MessageUrn]. Its wire
properties are the public instance properties with a public getter and the positional parameters of a record; fields,
static and computed (=>) properties, methods and [JsonIgnore] members are not. [JsonPropertyName] sets the wire
name; names are compared case-insensitively. Properties of base types declared in the sources are inherited. Public
enums follow the persisted-enums rules under enumStorage.
| Finding id | Class |
|---|---|
message-added |
safe; needs-action when one project publishes it and another consumes it (see below) |
message-removed |
needs-action: drain its queues before the deploy |
message-renamed |
breaking: a new full name or namespace changes the message URN; paired by simple name, else by an identical wire shape |
message-entity-name-changed |
breaking: [EntityName] changed, the builds publish to different exchanges |
message-base-added |
safe |
message-base-removed |
breaking: consumers of the base type or interface stop receiving it |
message-property-added |
safe when nullable (T?) or initialized (= null! and = default do not count); rollback-risk otherwise; breaking when required or [JsonRequired] |
message-property-removed |
breaking |
message-property-type-changed |
breaking (System. qualifiers, global::, Nullable<T> and type aliases are normalized first) |
message-property-nullability-changed |
rollback-risk: only ? changed; the message says what a null does to a value type (fails to deserialize or reads as a default), to a reference type (deserializes, then throws where code dereferences it) or, for a type declared outside the sources, both |
message-property-required |
breaking: an existing property became required or [JsonRequired] |
queue-added |
rollback-risk: after a rollback nothing consumes it; drain or delete it |
queue-removed |
needs-action: drain it before the deploy |
enum-member-added, enum-member-removed, enum-member-renamed, enum-member-renumbered, enum-member-unresolved, enum-added, enum-removed |
as in persisted-enums |
A new message is checked for deploy order in the revision's C# code. A project (the nearest folder with a .csproj)
publishes it through Publish<T>, Send<T>, Publish(new T ...), Send(new T ...) or IRequestClient<T>, and
consumes it through IConsumer<T>; build output and test projects (*.Tests) are skipped. When a project other than
the publisher consumes it, the finding is needs-action and names both with their first call site: deploy the
consumer (or declare its topology) first, because a publish to an exchange with no bound queue is dropped. A message
published through a variable (Publish(message)) is not seen, and two projects that deploy together still count as
apart; accept the finding then.
The layer fails, keeping its findings, when a source or queue source matches no file at either ref or loses all its
files in the revision, when a source declares no public type or a queue source finds no queue, and when a
declaration cannot be read: a #if in a type body, unbalanced brackets, an unrecognised public member, a
[JsonPropertyName], [MessageUrn] or [EntityName] without a constant string literal (an interpolated string
does not count), a type declared twice without partial, or a declaration-looking line outside comments that the
parser did not reach (a line inside a multi-line string counts too). It does not follow base types outside the
sources, custom [JsonConverter]s, or serializer settings other than MassTransit's defaults. A removed type and an
added one with the same unique wire shape are paired as a rename, reported as breaking even when the two messages
are unrelated.
Some changes no static layer can see: refactored code that must behave the same, query-string binding, payloads opened by old app versions. The layer starts the revision's stack, runs the base ref's black-box tests against it and reports every base test that fails there. It knows no stack: it runs your commands, each inside the materialized tree of its side.
{
"start": { "run": "SCRIPTS/rebuild-integration-stack.sh", "timeoutSeconds": 1800 },
"verify": { "run": "docker run --rm --entrypoint cat ghcr.io/acme/api:ci /app/REVISION", "expect": "{commit}" },
"test": {
"run": "dotnet test APP/API/TESTS/PETSEO.Integration.Tests --logger trx",
"results": { "kind": "trx", "path": "**/TestResults/*.trx" }
},
"stop": { "run": "docker compose -f VPS/DOCKER/TESTS/docker-compose.integration-tests.yml down -v" },
"collect": { "run": "docker compose -f VPS/DOCKER/TESTS/docker-compose.integration-tests.yml logs --no-color" },
"retries": 1,
"baseline": true,
"accept": [{ "test": "PETSEO.Integration.Tests.Legacy.*", "reason": "asserts the old paging on purpose" }]
}| Key | Meaning |
|---|---|
start |
{ side?, run, background?, ready?, timeoutSeconds? }, optional. side is revision (default) or base. A script runs to completion (default timeout 1800 s, a non-zero exit fails the layer); with background: true the command is a long-running app, kept alive during the tests and stopped with its whole process group afterwards, and ready (an http(s) URL) is polled until it answers 2xx within timeoutSeconds |
verify |
{ run, expect?, timeoutSeconds? } or { url, expect?, timeoutSeconds? }, optional: an identity probe that runs after start and before the tests in every cycle and proves the stack runs the code of the side start ran at. run is a command at that side (default timeout 120 s, a non-zero exit fails the layer) whose stdout is checked; url is an http(s) URL ({port} allowed) fetched once (default timeout 30 s) whose 2xx body is checked. expect defaults to {commit}; see below |
test |
{ side?, run, results: { kind, path }, timeoutSeconds? }. side is base (default) or revision; kind is junit (JUnit XML) or trx; path is a glob or list of globs relative to the tree root; default timeout 3600 s |
stop |
{ side?, run, timeoutSeconds? }, optional; side defaults to revision, timeout to 600 s. It always runs, also after a failed start, a test timeout or unreadable results |
collect |
{ side?, run, timeoutSeconds? }, optional: runs after a failed start, a failed test command or failing tests and before stop, so what only the running stack holds (container logs) survives the teardown; side defaults to the side stop runs at, timeout to 300 s. Its output is saved as a log; a failed collect is a note and does not fail the layer |
retries |
0 (default) to 5: the test command reruns while tests fail; a test that passes in any attempt passes, with a note |
baseline |
true runs a whole cycle with every command at the test side first (base tests against the base stack); tests failing there are not reported |
accept[] |
{ test, reason }; test is the full test name, * matches any run of characters |
Every command runs through the shell with COMPAT_SIDE, COMPAT_REF and COMPAT_COMMIT of its own tree and
COMPAT_PORT, a free TCP port picked for the cycle; {port} in run and ready is replaced by it, so tests can
reach a background app. The exit code of the test command is ignored when result files exist (failing tests exit
non-zero); files matching results.path are deleted before each attempt. A test name is classname.name in JUnit
XML and the full method name in TRX.
A start script that builds images under a fixed tag (:ci, :latest), rebuilds only what it thinks changed or
reuses a cached image from another tree can leave the base and the revision cycles running the same build, and a
green result then proves nothing. verify closes that gap. In expect, {commit} and {ref} expand to the
resolved commit and the ref of the side the stack was started at (the test side in the baseline cycle). The probe
matches when its trimmed output contains the expanded value; when that value is a full commit id, a hex word of at
least 7 characters in the output that is a prefix of it matches too, so an image labelled with a short SHA passes
(hex digits compare without case). A mismatch fails the layer, not a test: the error holds both what the stack
reported and what was expected, the tests do not run, and collect and stop still run. Without verify each
cycle adds the note the stack's code was not verified to be <side> (<ref>, <commit>). Each cycle also notes the
wall time of start (for a background app, until ready answered): two near-identical, very short start times
for the baseline and the check are a hint that nothing was rebuilt.
The full stdout and stderr of every start, test (each attempt), collect and stop command is written to a log
file named after the cycle, command and side, such as baseline-start-base.log, check-test-base-retry1.log or
check-collect-revision.log (and of a verify command, such as check-verify-revision.log), in the behaviour folder of --log-dir (default: a folder per run in the cache,
which nothing removes). A failure names its log file, the JSON report carries it with the last 40 lines of the
output in the layer's outputs, and the Markdown report shows those lines as a code block with their line breaks.
| Finding id | Class |
|---|---|
base-test-failed |
breaking: the base test failed (or errored, or timed out) against the stack; the message holds the first lines of its failure |
The layer fails, keeping its findings, when start fails or the app is not ready in time, when verify cannot
run or the stack reports another identity, when the test command
times out, cannot start, writes no result file or results without any test case, when a result file is not valid
JUnit XML or TRX, and when stop fails (the stack may still be running). init writes it disabled with example
commands: nothing in a repository says how its stack starts. The materialized trees are shared with the other layers of the run.
The v1 acceptance case is the PETSEO 2.2.4 → 2.3.4 release. The tool reproduces its HTTP contract, schema, data
migration, seed, persisted enum, configuration and dependency findings (covered by test/e2e/acceptance.test.ts), and
its message contracts and queues (test/e2e/message-contracts.test.ts). Query-string binding changes, push payloads
opened by old app versions and behaviour of refactored code are caught only by your own black-box tests through the
behaviour layer; messaging library behaviour beyond the version change is not checked.
Known gaps in 0.14.0:
- psql's
\copymeta-command in a seed script is not read; the statement after it is reported asunreadable-write. - MSBuild
Conditionattributes are decided only for'$(Name)' == ''and!= ''; a property that other conditions give different values stays unresolved and is reported conservatively (see dependencies). - A new error code counts against every live client whose map lacks it unless an
error-codesreturnedByentry names the operations that return it; which handler returns a code is not read from the server.
Open ideas live in context/backlog/later-layers.md; ideas weighed and rejected,
with the reason, are in the "Rejected" table of context/foundation/roadmap.md.
Releases are automatic: merging a version bump to master releases it. Nobody runs npm publish or pushes a tag.
npm version minor --no-git-tag-version # or patch / major / 0.2.0-rc.1; in a pull requestEvery push to master runs .github/workflows/release.yml. It runs every gate with
a real oasdiff, tests the packed CLI and packs the tarball; when the package.json version is not released yet, it
then:
- publishes
@softure-ai/compatto npmjs.com with provenance; - publishes
@softure/compatto GitHub Packages (GitHub requires the scope to match the org); - creates the tag
vX.Y.Zon the released commit and the GitHub Release with generated notes and the tarball, souses: SOFTURE/COMPAT@vX.Y.Zruns the new release.
There is no moving major tag (v0): GitHub does not let the workflow's own token create or move a tag on a commit
that holds workflow files.
A version with a prerelease suffix (0.2.0-rc.1) goes to the next dist-tag and is marked as a prerelease. A
version already on a registry is skipped, so re-running the workflow (Actions → SOFTURE COMPAT - RELEASE → Run
workflow on master) only fills in what is missing. A push that does not change the version only validates.
npm authentication, once per repository:
- First version: add the repository secret
NPM_TOKEN(an npm granular access token with read and write access to the@softure-aiscope; bypass 2FA enabled), then run the workflow onmaster. Without the secret the run publishes nothing and warns that the token is missing. - Afterwards: configure trusted publishing on npmjs.com, in the package settings: publisher GitHub Actions,
organization
SOFTURE, repositoryCOMPAT, workflowrelease.yml. npm then authenticates the workflow over OIDC and theNPM_TOKENsecret can be deleted; the workflow does not change.
Ways to install a release:
| Source | Command |
|---|---|
| npm | npm i -D @softure-ai/compat |
| GitHub Release (no auth) | npm i -D https://github.com/SOFTURE/COMPAT/releases/download/vX.Y.Z/softure-ai-compat-X.Y.Z.tgz |
| GitHub Packages | npm i -D @softure/compat with @softure:registry=https://npm.pkg.github.com and a token with read:packages |
MIT