AppSurface CLI
Source of truth
The AppSurface CLI is the command-line home for repository-level AppSurface workflows. It is packaged as a .NET tool with the command name appsurface.
The first public verb family is docs, which replaces the earlier standalone appsurfacedocs preview --repo . idea with AppSurface-owned preview and export commands:
appsurface docs --repo .
appsurface docs export --repo . --output ./dist/docs --mode cdn --strict
appsurface docs verify-archive --catalog ./docs-versions.json --version 1.2.3
appsurface docs runs the same AppSurface Docs standalone host used by CI and integration tests. It forwards AppSurface Docs configuration into that host instead of duplicating harvesting, routing, static web asset, or MVC setup in the CLI. appsurface docs export starts that same host in-process, binds an internal loopback listener, and delegates static crawling plus CDN validation to the RazorWire export engine. appsurface docs verify-archive checks one catalog-pinned exact release tree locally before deploy.
The CLI also includes public coverage commands for private-by-default CI coverage enforcement. appsurface coverage run discovers or accepts instrumented .NET test projects, writes local coverage artifacts, and merges Cobertura through the package-owned ReportGenerator dependency in the same command. appsurface coverage merge fans in existing Cobertura shards from matrix or custom test workflows without reading a consumer tool manifest. appsurface coverage gate evaluates the merged local Cobertura XML, writes JSON and Markdown reports, and can append the same Markdown to GitHub Actions step summaries without uploading coverage data to a hosted coverage service. appsurface coverage clean previews or explicitly removes those private artifacts; its default ownership-marker mode is narrow, while --all provides a deliberate worktree-wide TestResults sweep.
appsurface evidence adds a contract-first evidence layer above those local tools. It resolves a checked-in changed-risk policy from explicit paths or a CI-provided diff, explains the selected profile before work begins, diagnoses external prerequisites, records canonical plan/manifest artifacts, and refuses to call an incomplete or skipped profile complete evidence. Start with the EvidenceHost guide.
appsurface release compose gives any consumer project the same append-only release-note workflow AppSurface uses: each change adds its own Markdown entry, then one release owner validates and composes a deterministic note. It does not run AppSurface's repository-owned release cockpit, touch a shared CHANGELOG.md, create tags, or publish packages.
Coverage proof levels and driver boundary
There are two different coverage questions, and they have different commands:
| Need | Sequence | What it proves |
|---|---|---|
| Validate a repository's local coverage setup | coverage run --dry-run → coverage run → coverage gate |
The selected VSTest projects can produce the normal AppSurface artifacts and satisfy the repository's thresholds. |
| Validate a packed CLI candidate before publication | PackageIndex verify-packages |
The owned Smoke.Tests collector fixture executes Smoke.Calculator and retains its class, line, and branch facts from the selected raw report through the merged report. |
Start local readiness with coverage run --dry-run. It checks discovery, collector capability, scheduling, and artifact paths without running tests or cleaning output. Run coverage run next, then run coverage gate against its merged coverage.cobertura.xml. That local sequence is the normal consumer workflow; it does not claim semantic raw-to-merged proof for an arbitrary application graph.
The packaged proof is a release-validation workflow, not a replacement for that local sequence. PackageIndex installs the packed CLI in a clean fixture, selects exactly one manifest-bound Smoke.Tests report, validates the owned Smoke.Calculator class/line/branch invariants in the raw report, copies and hashes that shard, merges it, and independently validates the same invariants in merged Cobertura. The proof also retains the existing artifact, gate, excluded-project, patch-target, and canary checks. It is an owned fixture guard, not a certification of every consumer application.
Keep the assurance boundary explicit:
- The default
collectordriver is the semantic proof path for the packaged fixture and the supported VSTest integration for local runs. --coverage-driver msbuildis an explicit VSTest compatibility path for projects that directly referencecoverlet.msbuild. It proves artifact compatibility only; it is not equivalent to the packaged collector's semantic raw-to-merged proof.coverage mergeis fan-in only. Use it for externally produced Cobertura shards, then usecoverage gate; it does not prove that AppSurface's collector observed an owned consumer.--no-cleanis an intentional retention escape hatch. Preserved reports and patch-target files can be stale until a later gate refreshes them or a nonpatch gate removes them.- Native Microsoft Testing Platform (MTP) execution is a separate runner/integration boundary. The v1 collector path rejects MTP rather than switching drivers to satisfy a proof. Capture the runner and direct coverage-package classification, then use a separate MTP path or issue when MTP is selected or the facts conflict.
appsurface secrets manages the first-secret workflow for ForgeTrust.AppSurface.Config.LocalSecrets: initialize a local namespace, set one key, verify presence without printing the value, list names, explicitly migrate retained macOS Keychain records to v2, delete keys, and run doctor diagnostics for platform availability.
appsurface pwa verify checks install metadata or the optional server-known push-readiness surface served by ForgeTrust.AppSurface.Web. Push readiness includes worker/helper evidence and may report an optional VAPID-backed rail as not-configured on a no-provider sample. Install verification remains the default schema-v2 contract. Push verification is additive schema v3 evidence and keeps browser support, installation state, permission, subscription, notification display, and delivery outside the verifier's claims.
appsurface canary poll turns an existing protected named-canary evaluation into one bounded, read-only deployment decision. It owns the caller-visible poll cadence, deadline, response compatibility validation, and safe output; the application still owns authentication, proof evaluation, deployment actions, and traffic decisions.
Durable PostgreSQL schema commands
The source-preview CLI ships the following explicit deployment command family:
appsurface durable schema status
appsurface durable schema script --from-version <reviewed-version>
appsurface durable schema preflight
appsurface durable schema apply --apply
script is offline and deterministic from a reviewed installed version. The online status, preflight, and
explicit apply --apply operations resolve only the named APPSURFACE_DURABLE_CONNECTION environment variable by
default; deployments normally pass --connection-env APPSURFACE_DURABLE_MIGRATION_CONNECTION. None of these commands
accepts a connection-string argument or prints a connection string, credential, or environment-secret value.
apply --apply is the only database-mutating command in this family; it is an explicit migration-owner action and
is never performed by application startup. script --output can atomically publish or replace the named local file,
but it never opens a database connection. Write generated SQL only to an operator-controlled directory: atomic
publication protects readers from partial content, but it cannot make a directory shared with an untrusted local
principal safe from path replacement.
The preferred production flow remains: generate and review the offline schema script, apply migrations 0001 through
0007 in order, apply the canonical role recipe, run status and preflight, then explicitly enable
AddWorkerHost(). For recovery,
check status, correct and review the forward-only script, then retry; never delete migration history. The
durable-postgresql example is a local proof, not production
operations guidance.
Future CLI authentication is design-only today. The authenticated command design keeps auth centered on protected command execution, uses appsurface docs publish --archive ./dist/docs --site <site> as the first protected command wedge, and requires browser/loopback PKCE, RFC 8628 device flow, CI no-prompt behavior, secure token-cache boundaries, ASCLI1xx diagnostics, and packed-tool readiness proof before auth commands ship.
Release Guidance
AppSurface ships as a coordinated package family. Before installing this package from a prerelease feed, check the package chooser and release hub for current release risk, migration guidance, and readiness.
Install
Install the published tool from NuGet:
dotnet tool install --global ForgeTrust.AppSurface.Cli --prerelease
Update an existing global install with the same package id:
dotnet tool update --global ForgeTrust.AppSurface.Cli --prerelease
Verify the installed global or tool-path command reports the package SemVer exactly:
appsurface --version
Prerelease installs print values such as 0.1.0-rc.1, without a leading v or build metadata. Use the package artifact manifest, publish ledger, or release note when you need build provenance beyond the package version.
Use a local tool manifest when you want the command version pinned per repository:
dotnet new tool-manifest
dotnet tool install ForgeTrust.AppSurface.Cli --prerelease
dotnet tool run appsurface --version
dotnet tool run appsurface docs --repo .
dotnet tool run appsurface coverage run --solution ./MyApp.slnx --dry-run
dotnet tool run appsurface coverage run --solution ./MyApp.slnx
dotnet tool run appsurface -- release compose --root .
dotnet tool run appsurface coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 95 --min-branch 85 --diff-base origin/main --min-patch-line 95 --patch-line-mode codecov --min-patch-branch 85
Update the repo-scoped tool version with:
dotnet tool update ForgeTrust.AppSurface.Cli --prerelease
dotnet tool run appsurface --versionCommands
appsurface release compose
Use this command when multiple contributors or automated work streams need to describe release-facing changes without all editing the same living note or changelog. Each change writes one independent entry file; a release owner composes those entries in deterministic filename order. The command works in any project that adopts the small Markdown convention below—its section names belong to the consumer's template, not to AppSurface.
Create a stable living-note template once, with one marker per section:
# Unreleased
## Added
<!-- appsurface:unreleased-entries section="added" -->
## Fixed
<!-- appsurface:unreleased-entries section="fixed" -->
Then let each change add its own file under releases/unreleased.entries/. File names must be YYYY-MM-DD-topic.md, and the first line selects one template section:
<!-- appsurface:unreleased-entry section="added" -->
- Added a consumer-facing capability.
Entries may contain ordinary Markdown and nested ### headings, but not new # or ## sections, nested directories, symbolic links, composition markers, terminal control characters, or a section that the template does not declare. Section identifiers use lowercase letters, digits, and single hyphens. Relative Markdown links are rebased from an entry to the composed destination. Without --output, preview composes as though the template is the destination; with --output, both preview and --apply compose for that output file. Consequently, when the template and selected output live in different directories, those two previews can contain different relative link targets. Links inside inline, fenced, and indented code examples remain unchanged. Templates and entries may use tabs and normal line breaks, but other control characters are rejected so a preview cannot alter the maintainer's terminal.
Preview the complete composed document without writing any files:
dotnet tool run appsurface -- release compose --root .
Write a versioned or reviewable release note only after inspecting the preview:
dotnet tool run appsurface -- release compose \
--root . \
--output releases/v1.4.0.md \
--apply
--apply always requires a distinct --output; the command never overwrites the stable template, deletes source entries, creates tags, updates a shared CHANGELOG.md, or publishes anything. It rejects pre-existing links in selected path components, but should run under an operator-controlled root: pathname validation cannot make a directory shared with an untrusted local principal safe from a replacement after validation. Keep changelog rollover and entry consumption in your own reviewed release workflow. AppSurface's repository-owned ./eng/release cockpit adds those project-specific responsibilities and itself uses this same composition component; it is not part of the public tool contract.
| Option | Default | Behavior |
|---|---|---|
--root <directory> |
Current directory | Existing physical project root that bounds all selected paths. Linked roots and linked path components below the root are rejected. |
--entries <directory> |
releases/unreleased.entries |
Flat append-only entry directory below --root. A missing directory composes an empty template. |
--template <file> |
releases/unreleased.md |
Markdown template below --root that declares entry sections with composition markers. |
--output <file> |
Standard output | Destination below --root. Without --apply, reports the exact prospective write; with --apply, it must differ from --template. |
--apply |
Off | Writes the composed document to --output; otherwise the operation is preview-only. |
Start with this command when the problem is concurrent release-note or changelog authoring. Use the repository cockpit only when you own AppSurface's versioned evidence, package policy, tag binding, and publication workflows.
appsurface canary poll
Use this command after an application has registered and protected a named canary. It is not a liveness or readiness probe, does not trigger synthetic work, and never changes deployment state.
For a hands-on protected-trigger → proof → poll example, start with the named-canary adoption lab. It demonstrates how a caller can gate on the CLI exit code while the application keeps ownership of the workflow, evidence, and release decision.
AppSurface is open source: this CLI is a reusable deployment-proof client, while each application remains responsible for its protected endpoint, authorization policy, and deployment decision.
For a reproducible repository-local install, use this first-success path. The marker and token values remain in environment variables and must never be put directly on the command line:
dotnet tool install --local ForgeTrust.AppSurface.Cli --prerelease
export APPSURFACE_CANARY_TOKEN="..."
export APPSURFACE_CANARY_MARKER="deploy-$(git rev-parse --short HEAD)"
dotnet tool run appsurface -- canary poll \
--url https://app.example.com \
--name forwarding.alpha-evidence \
--bearer-token-env APPSURFACE_CANARY_TOKEN \
--marker-env APPSURFACE_CANARY_MARKER \
--fresh-since 2026-07-30T00:00:00Z
A successful first result has this safe shape:
PASS canary=forwarding.alpha-evidence attempts=1 elapsed=42ms
The command accepts only an application base URL, preserves any base path when it appends /_appsurface/canaries/{name}, and permits HTTP only for localhost, 127.0.0.1, and ::1. It does not follow redirects, sends no request before validating options and environment sources, and never renders markers, credentials, raw headers, raw URLs, or response bodies.
pass is the sole successful outcome. pending waits until the caller-owned deadline; fail, stale, and not-configured immediately return exit 3 with their exact outcome in text or JSON. --json writes one machine-readable terminal result with a diagnostic code, next action, and documentation URL. All non-pass text results start with a stable ASCAN4xx diagnostic and include the next action plus this section's documentation link.
| Exit | Diagnostic | Meaning | Operator action |
|---|---|---|---|
| 0 | — | pass |
Continue the deployment decision. |
| 2 | ASCAN401 / ASCAN402 |
Invalid local option or environment source. | Correct the option or environment variable before retrying. |
| 3 | ASCAN403 |
fail, stale, or not-configured. |
Read the exact result: investigate failure, refresh proof, or configure the host dependency. |
| 4 | ASCAN404 |
Authorization, route, redirect, media, or envelope protocol failure. | Verify host authorization and the named-canary compatibility core. |
| 5 | ASCAN405 |
Recoverable transport failures exceeded the configured allowance. | Restore target availability, then retry the deployment step. |
| 6 | ASCAN406 |
Caller-owned total deadline expired. | Increase --timeout only when the proof workflow genuinely needs longer. |
| 130 | ASCAN408 |
Caller cancelled polling. | Rerun when the deployment operation should continue. |
The default total timeout is 5m, the default cadence is 5s, and the default maximum consecutive recoverable transport failures is 3. Any --timeout and --interval pair is capped at 300 scheduled attempts, preventing an accidental high-frequency poll from overwhelming the protected endpoint. A valid Retry-After response is an advisory lower bound on the next wait; the CLI still owns scheduling and never sleeps beyond its total deadline. Bearer and identity values are limited to 16 KiB; each custom header value is limited to 4 KiB and all custom header values together to 16 KiB. --github-summary writes an escaped, bounded step summary when $GITHUB_STEP_SUMMARY exists; --no-github-summary suppresses it. A summary write warning (ASCAN407) never changes the already-decided exit code.
For GitHub Actions, pass values through env: and let the exit code gate the job:
- name: Verify named deployment canary
env:
APPSURFACE_CANARY_TOKEN: ${{ secrets.DEPLOY_OPERATOR_TOKEN }}
APPSURFACE_CANARY_MARKER: deploy-${{ github.run_id }}
run: >-
dotnet tool run appsurface -- canary poll
--url https://app.example.com
--name forwarding.alpha-evidence
--bearer-token-env APPSURFACE_CANARY_TOKEN
--marker-env APPSURFACE_CANARY_MARKER
--fresh-since 2026-07-30T00:00:00Z
Use --identity-token-env when the workflow already acquired an identity token. Hosts with another application-owned authorization header can use repeatable --header-env HEADER=VARIABLE; it is mutually exclusive with the Bearer modes and rejects reserved transport and canary headers. This command does not acquire, cache, or refresh tokens; does not install an authentication scheme; and does not ship a composite Action or a deployment controller.
appsurface pwa verify
Verify a running AppSurface Web app:
appsurface pwa verify --url https://app.example.com
appsurface pwa verify --base-url http://localhost:5055 --entry-path /account/resume --json
The verifier accepts HTTPS origins plus localhost, 127.0.0.1, and ::1 development origins. Use --entry-path for the real HTML page a user lands on after auth, resume, or setup redirects; the path is app-root-relative and is resolved under any path base in --url or --base-url. The verifier follows same-origin, same-base-path redirects for entry, manifest, diagnostics, icon, service-worker, and offline fallback requests, and fails when a redirect leaves that boundary.
--surface install|push|all selects the evidence surface. install is the default and preserves schema v2. push emits schema v3 server-known push-readiness evidence. all emits schema v3 with both install and push evidence. Entry-page discovery follows the selected surface: install looks for the manifest link, push looks for the AppSurface registration-helper metadata, and all checks both on the same entry page. --expect-push enabled|disabled applies only to push or all and defaults to enabled; passing it with --surface install is invalid. Install-only expectation flags are valid with install or all, not push. Use --diagnostics-path /your/pwa-diagnostics when the host changes PwaOptions.DiagnosticsPath; it defaults to /_appsurface/pwa and always probes that path's status.json child.
Use explicit assertions when CI should prove a product contract instead of only checking generic installability:
appsurface pwa verify \
--base-url https://app.example.com \
--entry-path /account/resume \
--expect-start-url / \
--expect-scope / \
--expect-display standalone \
--expect-theme-color '#2563eb' \
--expect-background-color '#ffffff' \
--expect-icon 192x192 \
--expect-icon 512x512 \
--json
The verifier reports stable ASPWA2xx diagnostics for manifest reachability, required manifest fields, required 192x192 and 512x512 icon tokens, optional expected icon declarations such as 512x512:maskable, icon content types, decoded PNG dimensions when available, entry-page manifest links, start_url/scope consistency, development diagnostics, and offline service worker plus offline fallback reachability when the app enables an offline strategy. When diagnostics expose push configuration, ASPWA257 records only that a push-capable worker was observed; registration, permission, subscription, and delivery were not evaluated. When no worker capability is enabled, the verifier probes the configured service-worker path and records proof that it is not mapped. JSON output uses schemaVersion: 2, preserves legacy passed, origin, manifestPath, and diagnostics fields, and adds baseUrl, entry URL, manifest fields, icon evidence, and structured diagnostic details for CI evidence. origin contains only scheme, host, and port; baseUrl includes the verified path base.
Push/all examples write named artifacts so CI can retain the bounded result:
mkdir -p artifacts
# Server-known push readiness only; writes schema-v3 evidence.
appsurface pwa verify \
--surface push \
--base-url https://app.example.com \
--entry-path /account/resume \
--expect-push enabled \
--json > artifacts/pwa-push-readiness.json
# Install plus server-known push readiness; writes schema-v3 evidence.
appsurface pwa verify \
--surface all \
--base-url https://app.example.com \
--entry-path /account/resume \
--expect-start-url / \
--expect-scope / \
--expect-display standalone \
--expect-icon 192x192 \
--expect-icon 512x512 \
--expect-push enabled \
--json > artifacts/pwa-all-readiness.json
# Prove that a host intentionally has no push posture; writes schema-v3 evidence.
appsurface pwa verify \
--surface push \
--base-url https://install-only.example.com \
--entry-path / \
--expect-push disabled \
--json > artifacts/pwa-push-disabled.json
The process exit code is authoritative. Treat a nonzero exit as failed verification even when a JSON artifact was written; the artifact is evidence for diagnosis, not a replacement for the command result. These checks inspect server-known configuration, generated routes, response headers, and HTML metadata only. They do not evaluate browser compatibility, installation, permission prompts, subscription state, notification display, click behavior, unsubscribe behavior, or push-service/browser delivery.
appsurface secrets
Manage AppSurface local development secrets before a remote vault exists.
appsurface secrets init --app MyApp --environment Development
printf '%s' "<secret>" | appsurface secrets set Stripe:ApiKey --app MyApp --environment Development --stdin
appsurface secrets doctor --app MyApp --environment Development
appsurface secrets list --names-only --app MyApp --environment Development
appsurface secrets get Stripe:ApiKey --app MyApp --environment Development
On macOS, get can report terminal local-secret-migration-required when a readable v1 record has no v2 counterpart.
Run the matching namespace migration before retrying the AppHost:
appsurface secrets migrate --app MyApp --environment Development
DOTNET_ENVIRONMENT=Development dotnet run
migrate never prints values, retains v1 records for recovery, and never overwrites an existing v2 value. V2 becomes
canonical once present, so update it through appsurface secrets set. Keep --app, --environment, and --prefix
identical to the runtime LocalSecrets options. The macOS v2 migration guide
includes status meanings and a three-key CLI/AppHost smoke.
get verifies presence and source without printing the secret value. list prints currently retrievable names only:
platform-backed stores validate indexed names against live values and silently remove stale names when validation and
repair succeed. If the platform store is locked, unavailable, or the index is corrupt, list fails with a paste-safe
diagnostic instead of hiding names it could not verify. delete KEY also repairs stale indexed names when the value is
already gone, while keys that never existed still report local-secret-missing.
doctor reports Problem, Cause, Fix, Docs, and Retryable so unsupported platforms, locked stores, and
headless sessions fail closed instead of falling through to file secrets. Use --store-file <path> only for
deterministic examples and tests; normal local development should use the OS-backed store when the platform adapter is
available. Use environment variables, key-per-file, or a remote vault for CI, containers, team environments, and
production.
When a command reports local-secret-store-unavailable, run appsurface secrets doctor --app <app> --environment <env>
for the same namespace. On Linux, verify the trusted secret-tool executable path and the current DBus or desktop
session. Startup failures before the platform command runs are reported as Unavailable with exception type, HResult,
and synthetic exit code only; raw OS exception messages, command paths, arguments, absolute paths, logical values, and
secret values are omitted by design.
For explicit file fallback, doctor can render these value-safe posture codes:
local-secret-store-ready
local-secret-file-posture-repaired
local-secret-file-posture-degraded
local-secret-file-posture-unsupported
ready, repaired, and degraded are doctor-style readiness results and exit successfully so setup scripts can keep
moving. degraded still means the file fallback is weaker than the OS-backed store; on Windows it is expected because
v1 does not claim Windows ACL hardening, and on Unix it is reserved for paths AppSurface can open but cannot fully
prove. unsupported fails the command and points to a normal per-user file path or OS-backed storage. On Unix, unsafe
path shapes, loose existing directories, loose file mode bits, and writable non-sticky ancestors use unsupported
rather than degraded. The file fallback creates missing directories with 0700 mode bits and tightens JSON file mode
bits to 0600; existing loose parent directories are rejected rather than modified in place. v1 does not claim
universal POSIX ACL proof.
On Linux, the OS-backed store runs Secret Service through secret-tool, but AppSurface does not execute secret-tool
from PATH. It uses /usr/bin/secret-tool, then /bin/secret-tool, unless you pass an explicit trusted absolute path:
SECRET_TOOL=/absolute/path/to/secret-tool
test -x "$SECRET_TOOL"
appsurface secrets doctor --app MyApp --environment Development --secret-tool-path "$SECRET_TOOL"
printf '%s' "<secret>" | appsurface secrets set Stripe:ApiKey --app MyApp --environment Development --secret-tool-path "$SECRET_TOOL" --stdin
Use --secret-tool-path for Nix, Linuxbrew, Guix, or custom prefixes after verifying the binary. The flag applies only
to the current CLI invocation; configure AppSurfaceLocalSecretsOptions.LinuxSecretToolPath in the app for runtime use.
--secret-tool-path and --store-file are mutually exclusive so doctor cannot report file-store readiness when you
meant to verify the Linux platform store.
appsurface secrets transfer
Create a value-free plan for a reviewed source-to-destination job, then revalidate and apply that artifact:
appsurface secrets transfer plan --config ./secret-transfer.json --job staging-to-production --out ./staging-to-production.plan.json
appsurface secrets transfer apply --config ./secret-transfer.json --plan ./staging-to-production.plan.json --apply --confirm staging-to-production
The JSON configuration declares named endpoints and exact jobs. local is the built-in LocalSecrets endpoint; Google
endpoints select either applicationDefault or a validated credentialFile. A job always supplies the logical key and
the exact remote source version or destination secret parent. Version 1 supports LocalSecrets-to-Google and
Google-to-Google transfer without exposing arbitrary --from/--to flags. Version 2 adds the deliberate,
plan-bound Google-to-LocalSecrets direction described below.
credentialFile requires an absolute regular-file path with owner-only file permissions. Its parent directories must
not use symbolic links and must not be writable by group or other users unless the directory uses the sticky bit.
Windows credential files fail closed because the CLI cannot verify an equivalent restrictive ACL; use explicitly
selected Application Default Credentials there.
plan performs metadata-only probes and records a configuration digest, expiry, canonical resources, and destination
preconditions. It never reads a secret payload. apply --apply requires the same configuration, rejects stale or changed
plans before reading values, and requires --confirm <job> when either endpoint is production-labelled. When a production job
declares allowMutableLocalSource: true, the same exact-job confirmation also acknowledges that the LocalSecrets value may
have changed since planning. Text and JSON summaries
may identify the requested plan or receipt artifact path. Summaries and plan/receipt JSON contain resources, actions,
diagnostic codes, and configuration/plan identity digests, but never secret values, payload bytes, secret-value hashes,
credentials, or raw provider exceptions.
Google destination secret parents must already exist. Without --replace, a Google destination with no enabled versions
receives its first enabled version, while a destination that already has an enabled version is skipped and independent
ready rows continue. With --replace, Google adds another enabled version to the existing secret parent and leaves older
versions under operator control.
The workflow never creates secrets, changes IAM, disables/destroys versions, rotates values, provisions Terraform, or
starts background synchronization. Writes are ordered but cross-secret atomicity is unavailable. During apply, the
workflow persists a value-free journal before the first mutation and updates it atomically after each row; the final
receipt is the durable summary used by --resume to skip rows already confirmed as written. A crash can therefore leave
a partial receipt, and an uncertain Google write is recorded as IndeterminateWrite and is never retried automatically.
Materialize one pinned Google version locally (v2)
For the complete local-test setup, including IAM prerequisites, guarded replacement, recovery, and runtime posture, start with Materialize a pinned remote secret for local testing.
Use this direction when you already have Google Secret Manager IAM access and need a reproducible local integration-test
clone. It does not create Google secrets, change IAM, or display a value. Choose the version number through Google
Secret Manager metadata—for example, gcloud secrets versions list db-password—then use the full numeric resource.
Version aliases, including latest, are rejected for every remote-to-local transfer because the plan must bind one
immutable source. See Google's version metadata guide
for the metadata-only listing workflow.
{
"version": 2,
"endpoints": [
{
"name": "production-gsm",
"provider": "google",
"environment": "production",
"credential": { "mode": "applicationDefault" }
}
],
"jobs": [
{
"name": "clone-production-db-password",
"source": "production-gsm",
"destination": "local",
"rows": [
{
"key": "Database:Password",
"source": "projects/my-project/secrets/db-password/versions/42"
}
]
}
]
}
The Google source must have secretmanager.versions.get for planning and secretmanager.versions.access for apply.
Run doctor and use the same local namespace flags for both plan and apply:
appsurface secrets doctor --app MyApp --environment Production
appsurface secrets transfer plan --config ./remote-to-local.json --job clone-production-db-password --app MyApp --environment Production --out ./clone-production-db-password.plan.json
appsurface secrets transfer apply --config ./remote-to-local.json --plan ./clone-production-db-password.plan.json --app MyApp --environment Production --apply --confirm clone-production-db-password
plan performs metadata-only probes. apply --apply accesses the pinned value only after every row passes preflight,
then emits a value-free CreatedLocalSecret, ReplacedLocalSecret, or RecoveredLocalSecret result and receipt. A
local target is missing by default. An existing target is a Conflict; a replacement requires --replace at plan
time and exact --confirm <job> at apply time, even when the remote source is not production-labelled. Legacy or
unattested local values must be deleted explicitly and planned again.
The v2 coordinator supports the built-in platform store and file fallback after doctor and transfer-state posture
checks; InMemoryAppSurfaceLocalSecretStore is test-only. Custom IAppSurfaceLocalSecretStore implementations receive
the value-safe local-secret-transfer-unsupported-store diagnostic in v2.
If the process stops after the local write begins, do not retry it blindly. Run apply with --resume <receipt> so
AppSurface can compare the pinned remote and local values in memory while holding the same per-key lock. Equal values
commit the prepared transfer; missing, different, unreadable, corrupt, or unsafe state remains Conflict or
IndeterminateWrite. Reconcile with appsurface secrets delete <key> and a fresh plan when needed. Local deletion
clears local transfer evidence and never changes the Google Secret Manager version.
Production is a valid local namespace label. It does not turn the laptop into a production host. A host application
that resolves a local namespace other than Development, Local, or Dev must explicitly set
LocalSecretsPostureMode.SingleMachineSelfHosted; the transfer CLI does not make that host configuration.
appsurface evidence
Use EvidenceHost when a CI gate must explain why evidence is required for a changed path and must never promote a skipped or incomplete test profile into a full-coverage claim. Start with the EvidenceHost guide. A selected coverage producer carries its exact coverageGate thresholds in the checked-in policy and run evaluates the existing gate in-process against that resolved policy.
appsurface evidence init --sample
appsurface evidence doctor --path src/Orders/SubmitOrder.cs
appsurface evidence explain --path src/Orders/SubmitOrder.cs
appsurface evidence run --diff-file ./artifacts/changed.patch --solution ./App.slnx
appsurface evidence verify ./TestResults/evidence/evidence-manifest.json
init --sample is safe to run in an existing repository: it creates a marked policy, host skeleton, and README and refuses to overwrite unmarked files. doctor does not start tests or resources; it reports whether the selected policy needs Docker, a browser runtime, or a protected release envelope. explain writes evidence-plan.json and a concise human summary before any producer runs. run writes the plan, evidence-manifest.json, and evidence-summary.json; it also appends a short claim summary to $GITHUB_STEP_SUMMARY when that CI-provided path exists. verify validates plan/manifest digest binding without rerunning producers.
The built-in producer reuses the private ForgeTrust.AppSurface.Evidence.Coverage engine used by coverage run and coverage gate; it writes the normal gate and patch-target artifacts beside its coverage output. When a policy requires patch thresholds, evidence run measures the same bounded --diff-file snapshot used to plan the selected profile, rather than reopening that path after execution starts. Browser E2E and resource-backed evidence belong in the separate consumer-owned ForgeTrust.AppSurface.Evidence.Aspire lifecycle. An explicit policy-selected empty profile may claim NoEvidenceRequired; unavailable capability, filtered required tests, a failed producer, a missing assertion, an unready declared resource, or a failed declared coverage threshold produces ClaimKind.None and command failure. --observation-only records a diagnostic claim that is deliberately ineligible for a PR or release gate.
The v1 workflow has no outbound telemetry, no automatic assembly/test discovery, no Docker sandbox, no independent artifact attestation, and no semantic classifier that silently labels arbitrary getters or constructors low value. Keep generated-code exclusions and the coverage thresholds in the reviewed Evidence policy explicit. See the EvidenceHost cookbook for policy, E2E, release-envelope, and incomplete-profile patterns.
appsurface coverage run
Run instrumented .NET test projects and merge private Cobertura artifacts.
appsurface coverage run \
--solution ./MyApp.slnx \
--output ./TestResults/coverage-merged
coverage run is the public package-consumer coverage orchestrator for private .NET repositories. It supports .sln and .slnx discovery, repeated --test-project selection, a default output directory that matches coverage gate, bounded parallel scheduling, per-project logs, stable per-project artifact directories, safe cleanup of AppSurface-owned outputs, managed JUnit test-result artifacts, optional slow-test diagnostics, and a package-owned ReportGenerator merge. Package consumers do not need a separate merge step: the command finishes by writing the merged coverage.cobertura.xml artifact. Cleanup also removes the UUID-named temporary files from an interrupted coverage gate, but leaves arbitrary dotfiles untouched. --no-clean preserves existing owned artifacts, including patch-target files produced by an earlier gate, so it is an explicit retention escape hatch and may leave stale targets until a later gate refreshes or removes them. It does not mutate consumer projects, install tools into the consumer repo, read the consumer .config/dotnet-tools.json, upload coverage, call GitHub APIs, or store trends.
Per-project coverage-normalization.log files are the extended diagnostic channel for secondary cleanup warnings. They are deliberately separate from dotnet-test.log, which failed runs replay to the console. The same warning appears as coverageCleanupDiagnostic, with its coverageCleanupLog path, in timings.json; coverageCleanupLog is null when AppSurface could not append the warning. Treat a warning as evidence that a temporary .coverage.*.tmp file may remain; the primary normalization outcome and the test result keep their original outcome.
The v1 contract assumes selected test projects use VSTest and are already instrumented with Coverlet. The default collector driver requires one direct coverlet.collector reference in every selected project. Native .NET 10 Microsoft Testing Platform execution is intentionally rejected because it uses a different runner and coverlet.MTP; see the .NET test runner selection and Coverlet integration guidance. No managed test result export happens by default. Use --test-results junit when AppSurface should own top-level JUnit artifacts, and make sure every selected test project references JunitXml.TestLogger. junit is the only managed test-result format supported in this release; trx and TUnit-compatible parsing are reserved for follow-up work. --logger remains raw dotnet test pass-through and does not create AppSurface-managed artifacts.
Exclusive projects always run first, in stable input order, so resource-sensitive suites do not wait for an ordinary parallel batch to drain. The remaining projects use input order by default. For repositories with enough projects for long-tail timing to matter, pass --schedule longest-first so non-exclusive projects with longer prior durations start first after exclusive projects complete. The first run usually has no timing history and keeps unknown projects in input order; later runs can reuse the previous output directory's timings.json automatically.
Already Has Coverlet
dotnet new tool-manifest
dotnet tool install ForgeTrust.AppSurface.Cli --prerelease
dotnet tool run appsurface coverage run --solution ./MyApp.slnx --dry-run
dotnet tool run appsurface coverage run --solution ./MyApp.slnx
dotnet tool run appsurface coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 85 --min-branch 75
Use --dry-run before the first real CI run to confirm project discovery, collector
capability, exclusive scheduling, and artifact paths without running tests or cleaning
the output directory.
appsurface coverage clean
The default mode cleans only AppSurface-owned coverage output. It uses the .appsurface-coverage-output ownership marker, removes only known AppSurface-owned coverage artifacts that a normal coverage run refreshes, preserves unrecognized files beside them, and never creates a missing output directory. Preview the standard output first:
appsurface coverage clean
When the listed coverage artifacts are expected, explicitly delete them:
appsurface coverage clean --apply
--apply is required: omitting it changes no files. --output ./TestResults/coverage-merged is the default, but a different existing AppSurface-owned output can be selected with --output. A populated output without the valid ownership marker fails closed with ASCOV109; an absent or empty unmarked directory is a no-op. This narrow default is the right choice for normal coverage maintenance because it never treats a generic folder name as proof that AppSurface owns its contents.
For a one-time worktree reclamation sweep, use the explicit broader mode:
appsurface coverage clean --all --root .
appsurface coverage clean --all --root . --apply
--all scans descendant directory names equal to TestResults without regard to case, reports the approximate regular-file bytes, and does not delete nearby names such as TestResults-old, bin, obj, or arbitrary artifacts. The root itself is never deleted, even when it is named TestResults; pass its parent directory to remove that folder. Point --root at one worktree—its default is the current directory—and do not run cleanup while tests or coverage collection are writing into the same tree.
For a bounded local-trust boundary, --all rejects a filesystem root and linked scan root, and does not traverse symbolic links or reparse points. A link inside a removed TestResults tree is unlinked with that tree, but its target is never read or removed. The displayed reclaimed total is an estimate measured before deletion, excludes linked content, and may differ if a process changes files between preview and apply. Deletion stops at the first filesystem failure; directories removed before that failure remain removed, so close the process holding the named path and rerun the command. Neither mode cleans bin, obj, package caches, Git worktrees, or remote artifacts.
| Option | Default | Behavior |
|---|---|---|
--output <directory> |
TestResults/coverage-merged |
AppSurface coverage output inspected by the default marker-owned mode. Cannot be combined with --all. |
--all |
Off | Scan every descendant TestResults directory below --root instead of only known AppSurface coverage artifacts. |
--root <directory> |
Current directory | Existing physical worktree scan boundary for --all; it is never deleted. Invalid without --all. |
--apply |
Off | Deletes the entries discovered for the current invocation; otherwise the command is a preview. |
| Diagnostic | Meaning | Operator action |
|---|---|---|
ASCOV109 |
The default coverage output path is unsafe or is not AppSurface-owned. | Use a dedicated output created by coverage run or coverage merge. |
ASTEST101 |
The --all cleanup root is invalid, missing, or cannot be inspected. |
Pass an existing readable worktree directory such as --root .. |
ASTEST102 |
The --all root is a filesystem root, symbolic link, or reparse point. |
Choose one physical non-root worktree directory. |
ASTEST103 |
A --all discovered directory could not be deleted. |
Close file handles for the reported path and retry --apply; already-removed directories stay removed. |
ASTEST104 |
The --all worktree could not be enumerated or measured completely. |
Restore readable filesystem access and retry before applying cleanup. |
Add Coverlet First
dotnet add tests/MyApp.Tests/MyApp.Tests.csproj package coverlet.collector
dotnet tool run appsurface coverage run --test-project tests/MyApp.Tests/MyApp.Tests.csproj
dotnet tool run appsurface coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 85 --min-branch 75
Add coverlet.collector to every selected VSTest project that should contribute coverage. coverage run evaluates direct package references before cleanup, build, or tests, but it intentionally does not edit project files or add packages on the consumer's behalf.
Coverage Driver Selection
--coverage-driver collector|msbuild selects the VSTest Coverlet integration. collector is the immediate default and never silently falls back. Each collector invocation writes into a unique collector-results/<run-id>/ directory, requires exactly one well-formed coverage.cobertura.xml, and atomically replaces the canonical per-project artifact only after validation. This prevents a successful test exit with a missing data collector, stale --no-clean output, or multiple attachments from looking like current coverage.
Use --coverage-driver msbuild only as an explicit compatibility path for a project that directly references coverlet.msbuild. The command prints a reliability warning because VSTest termination can interrupt the MSBuild integration before hit data is persisted. Migrate back to coverlet.collector rather than relying on a fallback.
Collector mode owns --collect, --results-directory, the -- runsettings separator, coverage runsettings, and --settings/-s. MSBuild mode owns the Coverlet /p: properties. Passing conflicting tokens through --test-argument fails before discovery or output mutation. --include and --exclude map to the selected driver's native configuration.
Project manifests and packaged semantic proof
Each executed project writes coverage-project.json beside its per-project coverage.cobertura.xml. Schema version 1 is intentionally small and dependency-free:
{
"schemaVersion": 1,
"projectPath": "tests/MyApp.Tests/MyApp.Tests.csproj",
"slug": "MyApp.Tests-0123abcd"
}
projectPath is the normalized solution-relative test-project path; slug is the CLI-owned artifact-directory identity. The manifest is capped at 16 KiB of UTF-8 JSON, and consumers must reject larger files before parsing. PackageIndex consumes this manifest instead of inferring identity from a directory name or recursively searching for an arbitrary XML file. A packaged proof must find exactly one manifest for Smoke.Tests/Smoke.Tests.csproj and a regular sibling coverage.cobertura.xml; missing, malformed, oversized, unsafe, duplicate, or mismatched manifests fail closed.
The PackageIndex semantic parser accepts only the bounded Coverlet/ReportGenerator Cobertura shape needed by the owned proof: coverage, sources, source, packages, package, classes, class, methods, method, lines, line, conditions, and condition. It prohibits DTDs and entities, uses a null XML resolver, caps each document at 1 MiB, and enforces maximum depth 32 and 10,000 elements. Unsupported required shapes, missing identity attributes, unsafe filenames, duplicate identities, or invalid numeric fields are tool-compatibility failures. A valid shape with missing or zero expected semantics is a coverage defect.
For both raw and merged reports, the owned fixture requires one Smoke package, one Smoke.Calculator class with a normalized filename ending in Smoke/Calculator.cs, an executed line with positive hits, and Sign line 7 with positive hits plus branch="True", condition-coverage="100% (2/2)", and one jump condition at 100%. The proof compares these semantic facts, not XML bytes; ReportGenerator may legitimately change document structure during merge.
PackageIndex writes the public-safe coverage-cli-consumer-proof.evidence.json beside the existing private coverage-cli-consumer-proof.md. Evidence schema version 1 contains only verdict, packageVersion, optional packageArtifactDigest, driverBoundary { runner, integration, directPackages, assuranceLevel }, raw { outcome, optional artifactRelativePath, optional sha256, invariants[] }, merged { outcome, optional artifactRelativePath, invariants[] }, and failures[] { code, scope, cause, nextAction, evidenceRelativePath }; every unavailable optional field is omitted rather than emitted as null. The driver-boundary runner is a configured proof identifier; the raw and merged outcomes state whether semantic validation actually ran. Evidence excludes command arguments and full invocation traces, working directories, NuGet sources/configuration, absolute paths, raw XML, credentials, and arbitrary output excerpts. CPV codes describe packaged semantic proof; ASCOV codes remain authoritative for the public CLI run, merge, and gate.
Manifest and evidence schemas evolve additively and by version. Schema-1 readers reject unknown required structure and unsupported major versions with an actionable incompatibility result. When upgrading Coverlet, ReportGenerator, or the runner:
- Capture one isolated generated-fixture raw and merged report.
- Confirm it stays within the documented Cobertura subset and preserves the owned class/line/branch invariants.
- Run the focused PackageIndex fixture suite and
verify-packages. - Update the versioned schema/subset documentation and evidence consumers together if the contract changed; do not silently broaden the parser.
Coverage Run Watchdog
Every discovery, capability preflight, build, test, merge, diagnostics, and artifact phase has its own monotonic progress clock. Positive child-process output bytes and explicit phase transitions count as progress; output from one parallel project never resets another project's clock. --heartbeat-interval defaults to 30s and accepts 0 to disable heartbeats. --no-progress-timeout defaults to 10m. Durations use exactly 0 or a positive lowercase integer followed by ms, s, m, or h, with a 30-day maximum; zero is valid only for the heartbeat interval.
--watchdog warn is the default: it writes a classified warning and attempts to commit coverage-watchdog.json, then lets the run continue. --watchdog fail cancels the run, requests whole-process-tree termination through supervisor-owned process leases, applies one bounded cleanup budget, attempts the incident artifact, and exits 124 with ASCOV121. The terminal diagnostic reports the artifact path only after atomic commit; Artifact: unavailable with ASCOV122 means termination remains authoritative but no incident artifact was committed. --watchdog off disables stall classification but leaves explicitly configured heartbeats available. Watchdog artifacts contain normalized operation metadata, byte counts, safe switch names, and relative project/log paths—not raw output, argument values, environment values, or secrets.
Require a non-sandboxed runner
--require-non-sandbox is an opt-in preflight for coverage workflows that must use an unrestricted host. It fails with ASCOV116 before discovery, output cleanup, build, or test execution when an explicit sandbox marker is enabled: CODEX_SANDBOX, SANDBOX_MODE, IN_SANDBOX, or IS_SANDBOX. Values 0, false, off, and no disable a marker. The diagnostic names the marker but never renders its value.
The guard does not infer a sandbox from generic Docker, container, or CI signals: those are valid coverage hosts and would create false positives. It also cannot prove that an unmarked environment is unrestricted. Use the option as a fail-fast policy for known agent sandboxes, not as an isolation detector or a sandbox escape mechanism. The repository wrapper enables the check by default; set COVERAGE_REQUIRE_NON_SANDBOX=false only when the restriction is intentional:
COVERAGE_REQUIRE_NON_SANDBOX=false ./scripts/coverage-solution.sh
Options:
--solution: Solution file used for discovery. Supports.slnand.slnx. When omitted, the current directory must contain exactly one.slnor.slnx.--test-project: Repeatable explicit test project path. Supplying one or more values skips solution discovery.--exclude-test-project: Repeatable normalized segment glob that excludes matching solution-discovered test projects from coverage execution. It cannot be combined with--test-project, and every pattern must match at least one discovered test project.--output: Coverage output directory. Defaults toTestResults/coverage-merged.--configuration: Build/test configuration. Defaults toDebug.--parallelism: Positive integer for non-exclusive test project concurrency. Defaults to1.--schedule: Project scheduling mode. Useinput-orderfor stable input order orlongest-firstto start longer non-exclusive projects first. Defaults toinput-order.--schedule-timings: Explicittimings.jsonfile for--schedule longest-first. When omitted, longest-first reads the previoustimings.jsonfrom the current output directory before cleanup.--priority-test-project: Repeatable non-exclusive project path or file name to schedule before duration-sorted projects when--schedule longest-firstis used.--no-restore: Passes--no-restoreto build and test commands.--build: Builds the solution once before tests, including explicit-project runs.--no-build: Skips the solution build before tests.--include: Coverlet include filter. Omit it to use Coverlet's project defaults.--exclude: Coverlet exclude filter. Defaults to[*.Tests]*,[*.IntegrationTests]*.--coverage-driver: VSTest coverage driver,collectorormsbuild. Defaults tocollector; no fallback occurs.--dry-run: Prints discovery, scheduling, and artifact paths without running tests.--list-projects: Lists selected and skipped projects without running tests.--no-discover-exclusive: Disables automatic exclusive classification for integration or Playwright-shaped projects.--exclusive-test-project: Repeatable project path or file name that should run exclusively.--logger: Repeatabledotnet testlogger value forwarded as--logger:<value>.--test-argument: Repeatable extra argument token appended to everydotnet testinvocation.--test-results: Managed test-result format. Usejunitto write AppSurface-owned top-level JUnit files. Other values fail before tests run.--slow-test-diagnostics: Writesslow-test-diagnostics.mdand.jsonfrom managed JUnit results. The Markdown starts with a bounded, failure-first test summary (counts, failed projects, and safely truncated failure evidence), then records slow-test timing diagnostics. This implies--test-results junit.--no-clean: Preserves existing AppSurface-owned output instead of cleaning known coverage artifacts first. Existing patch-target files therefore remain until a later gate refreshes them or runs without a patch source.--verbosity:dotnet testverbosity. Defaults tominimal.--heartbeat-interval: Heartbeat interval. Defaults to30s; exact0disables heartbeats.--no-progress-timeout: Positive per-operation progress timeout. Defaults to10m.--watchdog: Stall response,warn,fail, oroff. Defaults towarn.--require-non-sandbox: Fails before discovery or mutation when a known sandbox environment marker is enabled. Disabled by default.
Duration-aware scheduling starts exclusive projects before all non-exclusive projects. If discovery returns A.Tests, Browser.IntegrationTests, and B.Tests, the browser project runs first; AppSurface then orders the non-exclusive projects according to the selected schedule. This prevents an exclusive resource-sensitive project from waiting for an unrelated parallel batch to drain.
dotnet tool run appsurface coverage run \
--solution ./MyApp.slnx \
--parallelism 4 \
--schedule longest-first
Use --schedule-timings when CI stores the previous run's timings somewhere other than the output directory:
dotnet tool run appsurface coverage run \
--solution ./MyApp.slnx \
--schedule longest-first \
--schedule-timings ./artifacts/previous-coverage/timings.json
Use --priority-test-project for a known bottleneck that should start first even when its timing is missing or temporarily shorter. Priority projects must match one selected non-exclusive project. Duplicate, unmatched, ambiguous, or exclusive priority values fail before tests run so a typo does not silently change CI behavior.
Exclude Discovered Test Projects
Use repeatable --exclude-test-project values when a solution contains test projects that should still compile but must not participate in coverage execution:
dotnet tool run appsurface coverage run \
--solution ./MyApp.slnx \
--exclude-test-project "**/MyApp.Browser.Tests.csproj" \
--exclude-test-project "tests/*Generated.Tests.csproj" \
--dry-run
Patterns match solution-relative project paths after separators are normalized to /, and matching is case-insensitive on every operating system. A filename-only pattern matches the last path segment. * matches zero or more characters within one segment; ** matches zero or more complete path segments and must occupy a complete segment. All other characters, including ?, are literal. Exact paths and leading .. segments are supported for projects that the solution references outside its directory.
Patterns are intentionally strict: roots, drive-qualified or UNC paths, ., non-leading .., repeated or trailing separators, embedded **, empty values, and case-insensitive duplicates are rejected. Every normalized pattern must match at least one discovered test project; stale patterns fail with ASCOV112 before project reads, output cleanup, build, scheduling, or tests. Matching a non-test solution entry does not satisfy a pattern.
--list-projects and --dry-run print every skipped project and every matching exclusion pattern in stable solution and command-line order. Exclusions cannot be combined with explicit --test-project selection, and an excluded project cannot also be targeted by --exclusive-test-project or required as the only --priority-test-project candidate. If exclusions remove every discovered test project, the command fails with ASCOV105 and reports pattern match counts.
This option controls test execution, not the build graph. The default solution build still compiles excluded projects; use --no-build only when an earlier build already proved the solution. Do not confuse --exclude-test-project with Coverlet's --exclude, which filters instrumented assemblies while the test project still runs, or with --no-discover-exclusive, which changes scheduling classification without removing any project.
Artifacts are local and private by default:
coverage.cobertura.xml: Merged Cobertura file consumed bycoverage gate.summary.txt: Human-readable merged line and branch coverage summary.timings.json: Machine-readable build, test, merge, schedule, managed test-result, diagnostics, artifact, log, and exit-code data. Per-project entries include bothoriginalIndexfor stable artifact naming andexecutionIndexfor the actual launch order.executionStatusispending,running,completed,terminated, orskipped-after-terminal;coverageArtifactStatusisproduced,missing,multiple,unreadable,escaping,malformed, orskipped-after-terminal.coverageCleanupLogandcoverageCleanupDiagnosticrecord a non-fatal staged-file cleanup warning and its dedicated log-append result; the path isnullif the log append failed.coverageFileis non-null only when the current invocation produced and normalized that artifact, so--no-cleancannot make a retained stale file look current. Terminal failures write a best-effort snapshot after launched projects are drained so automation can distinguish work that failed from work that never started.reportgenerator-summary.txt: Text summary from the package-owned ReportGenerator merge when available.junit-coverage-<index>-<project-name-hash>.xml: AppSurface-managed JUnit test results when--test-results junitor--slow-test-diagnosticsis used.slow-test-diagnostics.mdandslow-test-diagnostics.json: A bounded failure-first test-result summary, slow-test evidence, parser warnings, metadata completeness, and diagnostic overhead when--slow-test-diagnosticsis used. The Markdown summary caps failed-test detail and total size for GitHub Actions; use the managed JUnit XML and project logs for complete evidence. The command writes these files through private same-directory staging files; a later normal clean run removes only GUID-named staging or backup remnants from an interrupted publication, while--no-cleanpreserves existing output.projects/<project-name-hash>/coverage.cobertura.xml: Per-project Coverlet Cobertura output.projects/<project-name-hash>/coverage-project.json: Schema-versioned project identity manifest consumed by PackageIndex proof; it contains the normalized solution-relative project path and CLI-owned slug.projects/<project-name-hash>/collector-results/<run-id>/: Unique raw collector attachment tree retained for diagnosis.projects/<project-name-hash>/dotnet-test.log: Fulldotnet testoutput for that project.projects/<project-name-hash>/coverage-normalization.log: Secondary collector-artifact cleanup diagnostics that are intentionally not replayed to the console.coverage-watchdog.json: Latest classified watchdog warning or termination, when one occurs..appsurface-coverage-output: Ownership marker containingAppSurface coverage output directory; it allows future runs to clean only known AppSurface-owned artifacts.
coverage run rejects unsafe output paths such as filesystem roots, the current working directory, the user home directory, the solution directory, test project directories, files, symbolic links or reparse points in any existing path component or artifact descendant, invalid ownership markers, and populated directories that do not carry the AppSurface ownership marker. An output tree created by an older version without the marker is not inferred to be owned from generic names such as projects or summary.txt; choose a fresh directory instead of adding a marker to an arbitrary populated tree. Validation is repeated through retained filesystem handles before cleanup so an ancestor replacement fails closed instead of redirecting deletion. This retained-handle guarantee covers output preparation and cleanup; later build, test, and report writes use the prepared path normally, so the output directory and its ancestors must remain trusted for the rest of the invocation. On Windows, staged report promotion requires delete sharing across the retained output path; the lease still rejects competing direct write handles on the output directory, but it cannot protect an output path shared with an untrusted local principal from rename or replacement. Use a dedicated artifact directory for CI, for example TestResults/coverage-merged, and do not replace or relink it while the command is running.
coverage run, coverage merge, and coverage gate are the supported CLI coverage surfaces. The package artifact verifier installs the packed ForgeTrust.AppSurface.Cli tool in a clean fixture and proves all three coverage commands, including patch-target creation and stale-target removal plus a deliberately failing gate that must still write reports, before publication. Grouped CLI execution and TRX/TUnit result parsing remain separate follow-up work.
Use this GitHub Actions shape for a private pull request workflow that already has Coverlet instrumentation. GitHub's checkout action fetches one commit by default; fetch-depth: 2 is enough to keep the default pull request merge checkout and let the patch gate compare against the merge commit's base parent without fetching full history:
- uses: actions/checkout@v5
with:
fetch-depth: 2
persist-credentials: false
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet tool restore
- run: dotnet restore ./MyApp.slnx
- run: dotnet tool run appsurface coverage run --solution ./MyApp.slnx --configuration Release --no-restore --test-results junit --slow-test-diagnostics
- name: Gate coverage
shell: bash
env:
COVERAGE_GATE_DIFF_BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: |
gate_args=(coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 85 --min-branch 75)
if [[ -n "$COVERAGE_GATE_DIFF_BASE" ]]; then
gate_args+=(--diff-base "$COVERAGE_GATE_DIFF_BASE" --min-patch-line 85 --min-patch-branch 75)
fi
dotnet tool run appsurface "${gate_args[@]}"
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: |
TestResults/coverage-merged/coverage.cobertura.xml
TestResults/coverage-merged/summary.txt
TestResults/coverage-merged/timings.json
TestResults/coverage-merged/junit-*.xml
TestResults/coverage-merged/slow-test-diagnostics.md
TestResults/coverage-merged/slow-test-diagnostics.json
TestResults/coverage-merged/coverage-watchdog.json
TestResults/coverage-merged/coverage-gate.json
TestResults/coverage-merged/coverage-gate.md
TestResults/coverage-merged/coverage-patch-targets.json
TestResults/coverage-merged/coverage-patch-targets.md
TestResults/coverage-merged/projects/**/dotnet-test.log
TestResults/coverage-merged/projects/**/coverage-normalization.log
GitHub's default pull_request checkout is the synthetic merge commit. fetch-depth: 2 brings in the merge commit and its base parent, so --diff-base HEAD^1 reports the pull request changes as tested by the job without fetching the full repository. If fetch-depth: 2 is omitted, actions/checkout fetches only HEAD, HEAD^1 is unavailable, and the gate fails closed with ASCOV010. If a workflow checks out the pull request head instead, use a head-vs-base source for that same tree; do not reuse merge-ref coverage artifacts with a head diff.
Coverage Efficiency Evidence Workflow
AppSurface’s repository-only Coverage Efficiency Evidence workflow
is a manual cold/warm comparison path for issue #728,
not a new public coverage run option or an ordinary pull-request gate. It invokes the existing
./scripts/coverage-solution.sh lane with BUILD_CONFIGURATION=Release,
BUILD_NO_RESTORE=true, COVERAGE_PARALLELISM=2, and an empty COVERAGE_GATE_DIFF_BASE.
The empty diff base intentionally measures the aggregate coverage lane, rather than the synthetic
pull-request merge diff. The workflow preserves the wrapper’s --require-non-sandbox protection,
runs only from the trusted default branch with read-only contents permission and no secrets, and uses a fixed 14-day,
if-no-files-found: error artifact contract.
Open environment-manifest.json in the retained coverage-efficiency-evidence artifact before
comparing samples. It records exact coverage-step start/end timestamps and duration, commit/run URL,
runner OS/image, .NET SDK details, Docker and pinned PostgreSQL image evidence, Node/pnpm, and the
Playwright browser inventory. The same artifact packages the explicitly allowlisted timings.json,
managed JUnit XML, per-project dotnet-test.log and any emitted coverage-normalization.log,
slow-test diagnostics, Cobertura/summary output, reportgenerator-summary.txt when emitted, coverage-gate reports,
and resolved-serial-set.json. That private report identifies each exclusive barrier and the preceding
parallel batch it drains. Read evidence-completeness.json before trusting a sample: captureStatus
describes whether capture completed, artifactContractComplete says whether the required evidence set
is complete, and artifactContractErrors lists any contract failures. The workflow deliberately does
not upload an unbounded TestResults tree.
The primary metric is the high-resolution monotonic duration of the coverage-wrapper invocation. timings.json project seconds
starts before project execution and ends after coverage normalization, so it is end-to-end project-run
attribution rather than test-process time. Use its schedule fields with resolved-serial-set.json to
reconcile issue-named, explicit, automatic, and actual serial-set projects. Do not treat a shorter project value as an improvement
unless the same cold or warm class also lowers the exact step’s five-sample median by at least 15% and
five seconds without moving delay to an adjacent barrier batch.
Use coverage run --dry-run or --list-projects to inspect selected projects, automatic
exclusivity, and artifact paths before a run. Those commands are preflight only: they do not produce a
baseline sample. The committed issue #728 evidence templates
define the scope reconciliation, candidate admission, timing formulas, failure-injection proof, and
no-change-ceiling handoff. If Docker/PostgreSQL or Chromium is unavailable, a runtime fingerprint
differs, an artifact is incomplete, or a sample class remains noisier than 10% after its one retry,
the workflow retains the diagnostic artifact but fails the sample. Fix or record that condition and do
not make a time claim.
appsurface coverage merge
Merge existing Cobertura shards that another workflow already produced.
appsurface coverage merge \
--source ./TestResults/coverage-shards \
--output ./TestResults/coverage-merged
Use coverage merge when a CI matrix, custom test harness, or non-AppSurface test producer already writes Cobertura files and you only need AppSurface's package-owned fan-in plus coverage gate artifacts. Use coverage run -> coverage gate for normal package-consuming .NET repositories where AppSurface should discover projects, invoke dotnet test, and merge the Coverlet output. In this repository, the default no-argument ./scripts/coverage-solution.sh lane runs that pair with repository thresholds; focused selection and shard fan-in always use the public CLI commands above.
When coverage merge receives exactly one validated shard, it still runs the package-owned ReportGenerator dependency and writes its normal summary/timings artifacts. Its final merged Cobertura starts from the validated ReportGenerator output, then restores only the staged source's direct class-line branch/condition details that ReportGenerator can omit during a no-op transformation; the resulting report remains gate-compatible and has no external DTD. With two or more shards, the merged Cobertura comes from ReportGenerator as usual.
The v1 source contract is intentionally narrow. --source must point to an existing directory. The command recursively selects files named exactly coverage.cobertura.xml, sorts them by ordinal path, validates that each selected file has a Cobertura <coverage> root, and prints the discovered count plus the first few relative paths. A single shard is valid. Files named Cobertura.xml, arbitrary *.xml, or non-Cobertura XML are not accepted by v1; rename or copy producer artifacts to coverage.cobertura.xml before merging.
Options:
--source: Required directory containing one or morecoverage.cobertura.xmlshard files.--output: Coverage output directory. Defaults toTestResults/coverage-merged.
Artifacts are local and private by default:
coverage.cobertura.xml: Merged Cobertura file consumed bycoverage gate.summary.txt: Human-readable merged line and branch coverage summary.timings.json: Machine-readable merge duration, selected shard count, selected input paths, ReportGenerator exit code, and merged Cobertura path.reportgenerator-summary.txt: Text summary from the package-owned ReportGenerator merge when available.reportgenerator-input/: AppSurface-owned staged copies of selected inputs, using deterministic sanitized shard directories..appsurface-coverage-output: Ownership marker that allows future merges to clean only known AppSurface-owned merge artifacts.
coverage merge rejects unsafe output paths such as filesystem roots, the current working directory, the user home directory, files, source/output overlap in either direction, and populated directories that do not carry the AppSurface ownership marker. Use separate shard and output directories, for example TestResults/coverage-shards and TestResults/coverage-merged.
Use this GitHub Actions fan-in shape when matrix jobs upload Cobertura shard artifacts:
jobs:
test:
strategy:
matrix:
shard: [unit, integration]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet restore ./MyApp.slnx
- run: dotnet test ./tests/${{ matrix.shard }} --collect:"XPlat Code Coverage"
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-${{ matrix.shard }}
path: '**/coverage.cobertura.xml'
coverage:
needs: test
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 2
persist-credentials: false
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet tool restore
- uses: actions/download-artifact@v4
with:
pattern: coverage-*
path: ./TestResults/coverage-shards
merge-multiple: false
- run: dotnet tool run appsurface coverage merge --source ./TestResults/coverage-shards --output ./TestResults/coverage-merged
- run: dotnet tool run appsurface coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 85 --min-branch 75 --diff-base HEAD^1 --min-patch-line 85 --min-patch-branch 75
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: |
TestResults/coverage-merged/coverage.cobertura.xml
TestResults/coverage-merged/summary.txt
TestResults/coverage-merged/timings.json
TestResults/coverage-merged/coverage-gate.json
TestResults/coverage-merged/coverage-gate.md
TestResults/coverage-merged/coverage-patch-targets.json
TestResults/coverage-merged/coverage-patch-targets.mdCoverage Merge Diagnostics
Every merge diagnostic uses the ASCOV130 through ASCOV139 range and includes the problem, likely cause, exact fix, and docs anchor.
| Code | Meaning | Fix |
|---|---|---|
ASCOV130 |
The required source path is missing, invalid, unreadable, or not a directory. | Pass --source with an existing readable directory that contains shard artifacts. |
ASCOV131 |
No coverage.cobertura.xml files were found. |
Download or copy shard artifacts under --source, keeping the exact file name. |
ASCOV132 |
A selected input is malformed, unreadable, or not Cobertura XML. | Regenerate the shard or remove non-Cobertura files before rerunning. |
ASCOV133 |
Source and output overlap or alias each other. | Keep downloaded shards and merged artifacts in separate dedicated directories. |
ASCOV134 |
The package-owned ReportGenerator dependency was not found. | Restore or reinstall ForgeTrust.AppSurface.Cli so its package dependencies are present. |
ASCOV135 |
ReportGenerator failed, did not produce merged Cobertura, or produced malformed merged Cobertura. | Inspect selected shards and ReportGenerator output, then rerun after fixing the inputs. |
ASCOV136 |
The output path is unsafe or not AppSurface-owned. | Use a dedicated AppSurface-owned output directory. |
ASCOV137 |
Staging or artifact writes failed. | Use a writable dedicated output directory and rerun. |
ASCOV138 |
A source or output path shape could not be normalized safely. | Use ordinary directory paths without invalid characters or unsupported path forms. |
ASCOV139 |
AppSurface staging did not preserve every selected shard. | Clean the output directory or choose a fresh output path, then rerun. |
Coverage Run Diagnostics
Every ASCOV### diagnostic includes the problem, likely cause, exact fix, docs anchor, and a log path when a project log is available.
| Code | Meaning | Fix |
|---|---|---|
ASCOV101 |
A command option or project path is invalid. | Correct the option value, pass an existing test project, or use a valid dedicated output path. |
ASCOV102 |
Solution discovery or dotnet sln <solution> list failed. |
Pass a valid .sln/.slnx, fix the solution file, or use repeated --test-project. |
ASCOV103 |
No Coverlet Cobertura files were produced. | Add the package required by the selected driver to every selected VSTest project, then rerun. |
ASCOV104 |
ReportGenerator merge failed. | Inspect per-project Cobertura files and rerun with --dry-run to verify selected projects. |
ASCOV105 |
Discovery selected no test projects, including when exclusions remove every test project. | Narrow --exclude-test-project, rename test projects to match *Tests.csproj, or use explicit --test-project values without exclusions. |
ASCOV106 |
The merged Cobertura file is malformed. | Regenerate coverage and inspect ReportGenerator output. |
ASCOV109 |
The output path is unsafe or not AppSurface-owned. | Use a dedicated artifact directory such as TestResults/coverage-merged. |
ASCOV110 |
dotnet build, dotnet test, or process startup failed. |
Fix the build/test failure and inspect the listed project log. |
ASCOV111 |
An unsupported managed test-result format was requested. | Use --test-results junit, omit --test-results, or keep custom loggers on --logger. |
ASCOV112 |
An exclusion pattern matched no discovered test project. | Run with --list-projects, then correct or remove each stale --exclude-test-project pattern. |
ASCOV113 |
The runner or evaluated direct package references are incompatible with the selected driver. | Use VSTest and add coverlet.collector, or explicitly select msbuild with coverlet.msbuild. |
ASCOV114 |
The package-owned ReportGenerator dependency was not found. | Restore or reinstall ForgeTrust.AppSurface.Cli so its package dependencies are present. |
ASCOV115 |
Collector output was missing, multiple, malformed, or escaped its invocation directory. | Inspect the project log and raw collector results, fix the producer, and rerun. |
ASCOV116 |
--require-non-sandbox found an enabled sandbox marker. |
Run coverage outside the sandbox or omit the option when the restriction is intentional. |
ASCOV120 |
One or more test, merge, manifest, or artifact steps failed. | Open timings.json and per-project logs listed above, fix the reported failure, then rerun. |
ASCOV121 |
Fail-mode watchdog termination claimed the run. | Inspect the reported watchdog artifact path and project logs; if the path is unavailable, use the ASCOV122 detail, then fix the stall or tune the timeout and rerun. |
ASCOV122 |
A bounded watchdog artifact write or promotion failed. | Use a writable dedicated output directory; the process cancellation outcome remains authoritative. |
ASCOV123 |
A failed collector-artifact normalization also left its staged temporary file behind. | Inspect the project's coverageCleanupLog in timings.json, then remove the .coverage.*.tmp file after confirming no coverage run is active. |
appsurface coverage gate
Enforce a private coverage quality gate from an existing Cobertura XML file.
appsurface coverage gate \
--coverage ./TestResults/coverage-merged/coverage.cobertura.xml \
--min-line 95 \
--min-branch 85 \
--diff-base HEAD^1 \
--min-patch-line 95 \
--min-patch-branch 85
coverage gate is the stable v1 coverage API. It does not run tests, merge shards, upload coverage, call GitHub APIs, or store trends. It reads one Cobertura file, evaluates line and branch percentages, optionally estimates changed-line and changed-branch coverage from exactly one patch diff source, writes coverage-gate.json and coverage-gate.md, prints the result, and exits nonzero when any configured threshold fails. When $GITHUB_STEP_SUMMARY is set, the Markdown report is appended by default so GitHub Actions logs show the gate result without requiring Codecov or another hosted dashboard. Use --no-github-summary when a workflow wants only file artifacts. When exactly one of --diff-base, --diff-file, or --diff-stdin is supplied, the gate also writes coverage-patch-targets.json and coverage-patch-targets.md in the selected output directory, regardless of whether patch thresholds were configured.
Options:
--coverage: Cobertura XML file to evaluate. Defaults toTestResults/coverage-merged/coverage.cobertura.xml.--min-line: Minimum line coverage percentage from0through100. Defaults to0.--min-branch: Minimum branch coverage percentage from0through100. Defaults to0.--tolerance: Grace margin from0through100subtracted from every configured overall and patch threshold before evaluation. Defaults to0.5.--diff-base: Git ref or commit compared withHEADfor patch coverage. When set, the command runsgit diff --unified=0 --no-ext-diff --relative <base>...HEAD --from the repository root.--diff-file: Unified diff file for patch coverage. Use this for CI systems that can download a pull request or compare diff without fetching full repository history.--diff-stdin: Read unified diff text from stdin. Use this with pipes or redirected input; interactive stdin fails fast so local terminals do not appear hung.--diff-label: Optional display label for the selected patch source in JSON, Markdown, and GitHub summaries.--repository-root: Repository root used to normalize Cobertura paths and diff paths. Defaults to the Git worktree root when available, otherwise the current directory.--min-patch-line: Minimum changed-line coverage percentage from0through100. Requires exactly one patch source:--diff-base,--diff-file, or--diff-stdin. Omit it to report changed-line coverage without gating on it.--patch-line-mode: Changed-line calculation mode, eithermeasurable(the backwards-compatible default) orcodecov, case-insensitive. Requires exactly one patch source.--min-patch-branch: Minimum changed-branch coverage percentage from0through100. Requires exactly one patch source. Omit it to report changed-branch coverage without gating on it.--output: Directory forcoverage-gate.json,coverage-gate.md, and, when patch evaluation is active, both patch-target files. Defaults to the coverage file directory; the standardcoverage runoutput isTestResults/coverage-merged.--github-summary: Append Markdown to$GITHUB_STEP_SUMMARYwhen it is set. Enabled by default.--no-github-summary: Suppress GitHub step summary output.
Use the default tolerance to absorb insignificant coverage-report rounding differences. Set --tolerance 0 for strict enforcement. The effective threshold is never lower than 0, and console output plus Markdown reports show the effective overall and patch thresholds. JSON reports include tolerancePercent and effectiveThresholds, while preserving configured thresholds for existing automation; invalid tolerance values fail with ASCOV007 before report generation.
The command accepts Cobertura root attributes such as line-rate, branch-rate, lines-covered, lines-valid, branches-covered, and branches-valid. XML parsing disables DTD processing and external resolution. Coverage counts must be non-negative, covered counts cannot exceed valid counts, rates must be from 0 through 1, and zero valid line or branch counts fail with ASCOV006 because a quality gate with no measurable denominator is misleading.
Patch coverage counts added or modified diff lines, intersects those lines with Cobertura <class filename> and <line number hits> entries, and reports covered/measurable lines. The default measurable mode counts a Cobertura line as covered when hits > 0, preserving the original local gate behavior. codecov mode retains only changed lines present in Cobertura and counts a line as covered when hits > 0 and it has no branch data or full condition coverage (covered == valid); partial condition coverage therefore counts as an uncovered line, matching Codecov's patch-line calculation. Lines absent from Cobertura remain excluded in both modes, including test-only or otherwise non-instrumented modules that Codecov excludes. The patch-line mode affects only the patch line metric; --min-patch-branch remains a separate branch-condition metric and still counts changed Cobertura conditions. When a diff has no measurable changed lines or no measurable changed branches, the corresponding patch metric reports 100% and says so explicitly in Markdown. Empty external diff files or stdin are valid empty patches. Non-empty malformed external artifacts, such as HTML login pages or JSON API errors, fail before coverage is evaluated.
Agent-actionable patch targets
The target artifacts are a compact local remediation queue derived from the same diff and
Cobertura evidence as the gate. They are written beside the existing reports in the selected
gate output directory; the default coverage run location is TestResults/coverage-merged.
The directory is already ignored by the repository's existing
TestResults rule, so target files are intentionally not added as separate
.gitignore entries and should remain private local artifacts.
Target extraction is threshold-independent: supplying exactly one of --diff-base, --diff-file,
or --diff-stdin writes both coverage-patch-targets.json and coverage-patch-targets.md, even
when --min-patch-line and --min-patch-branch are omitted. The JSON is schema version 1 with
repository-relative path, one-based line, reasons, lineCovered, optional condition counts,
and gateDimensions. Reasons are limited to uncovered-line and partial-condition; the
report does not infer a missing Boolean outcome, test project, test name, or source excerpt.
Lines not reported by Cobertura appear only as bounded notMeasuredSamples context, never as
targets. In codecov mode a partial condition contributes to the patch-line target semantics;
in measurable mode a covered line with a partial condition is branch-only, while an unexecuted
line remains a line target.
For an active patch comparison, an empty targets queue means the patch is fully covered for the
selected gate semantics. A nonpatch gate intentionally removes the two target files from the
validated output directory, so their absence after a nonpatch run is expected and prevents stale
work from being consumed. A report-file create, replace, or delete failure is an artifact I/O
failure reported as ASCOV019; check the named path and use a writable dedicated output directory.
The gate rejects symbolic-link or reparse-point output components, stages each report before
promotion, and retains the validated output directory while it removes reports. On Windows the
lease denies competing direct writes on the output directory but permits delete sharing across the
retained output path for staged promotion, so a shared output directory remains a
trusted-local-principal boundary. Do not relink, replace, or run a second gate against that
directory until the first command finishes.
The local agent loop is:
- Read
coverage-patch-targets.md(or inspect the JSON). - Open each named source
path:line. - Identify the relevant test project from the source and repository structure.
- Run
dotnet test <project> --no-restoreto validate the focused behavior. - Run the full coverage workflow and one final full
coverage gateto refresh the queue and prove the result.
coverage run --no-clean preserves owned target files exactly like other owned artifacts. Use it
only when that retention is intentional; the preserved queue can be stale until the next gate
refreshes it with a patch source or removes it with a nonpatch gate.
Reports are private local artifacts:
{
"passed": false,
"coverage": "/repo/TestResults/coverage-merged/coverage.cobertura.xml",
"tolerancePercent": 0.5,
"thresholds": {
"line": 95,
"branch": 85,
"patchLine": 95,
"patchBranch": 85
},
"effectiveThresholds": {
"line": 94.5,
"branch": 84.5,
"patchLine": 94.5,
"patchBranch": 84.5
},
"patchLineMode": "codecov",
"patchDiffSource": {
"kind": "git-base",
"label": "HEAD^1",
"strictness": "local-git",
"diffBase": "HEAD^1",
"path": null,
"bytes": 1024,
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"empty": false
},
"line": {
"covered": 80,
"valid": 100,
"percent": 80
},
"branch": {
"covered": 30,
"valid": 50,
"percent": 60
},
"patchLine": {
"diffBase": "HEAD^1",
"changed": 28,
"measurable": 20,
"covered": 18,
"percent": 90
},
"patchBranch": {
"diffBase": "HEAD^1",
"changed": 28,
"measurable": 8,
"covered": 7,
"percent": 87.5
}
}
Use coverage gate after coverage run, or after any other private coverage workflow that produces a local Cobertura file:
- uses: actions/checkout@v5
with:
fetch-depth: 2
persist-credentials: false
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- run: dotnet tool restore
- run: dotnet restore ./MyApp.slnx
- run: dotnet tool run appsurface coverage run --solution ./MyApp.slnx --configuration Release --no-restore
- name: Gate coverage
shell: bash
env:
COVERAGE_GATE_DIFF_BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: |
gate_args=(coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --min-line 95 --min-branch 85)
if [[ -n "$COVERAGE_GATE_DIFF_BASE" ]]; then
gate_args+=(--diff-base "$COVERAGE_GATE_DIFF_BASE" --min-patch-line 95 --min-patch-branch 85)
fi
dotnet tool run appsurface "${gate_args[@]}"
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: |
TestResults/coverage-merged/coverage.cobertura.xml
TestResults/coverage-merged/summary.txt
TestResults/coverage-merged/timings.json
TestResults/coverage-merged/coverage-gate.json
TestResults/coverage-merged/coverage-gate.md
TestResults/coverage-merged/coverage-patch-targets.json
TestResults/coverage-merged/coverage-patch-targets.md
TestResults/coverage-merged/projects/**/dotnet-test.log
TestResults/coverage-merged/projects/**/coverage-normalization.log
CI upload is optional and is not part of the local remediation workflow. If an existing workflow
already uploads TestResults/coverage-merged, include both target files above in that same
artifact list; do not add a workflow, hosted consumer, or code-writing agent just to retain them.
For repositories with an existing coverage producer, replace the coverage run step and the --coverage path with the command and Cobertura file path your test setup actually produces.
Use --diff-base origin/main for local development or simple CI jobs that already fetched the base ref. Use --diff-file when CI already produced a unified diff artifact, and use --diff-stdin when another command streams unified diff text:
git diff --unified=0 --no-ext-diff --relative origin/main...HEAD -- \
| appsurface coverage gate --coverage ./TestResults/coverage-merged/coverage.cobertura.xml --diff-stdin --diff-label origin/main...HEAD --min-patch-line 95
For GitHub pull requests, prefer the default merge checkout with fetch-depth: 2 and --diff-base HEAD^1. This keeps patch line numbers aligned with the tree that produced coverage while still avoiding full history. If HEAD^1 cannot be resolved, the checkout is probably still at the default depth of 1; add fetch-depth: 2 to the checkout step. If your CI checks out the pull request head instead, generate or download a head-vs-base diff for that same tree and pass it with --diff-file or --diff-stdin.
Diagnostics use ASCOV### codes so CI logs are searchable:
| Code | Meaning | Fix |
|---|---|---|
ASCOV001 |
The Cobertura file is missing or --coverage is blank. |
Produce coverage first or pass the correct file path. |
ASCOV006 |
The Cobertura file is malformed or has unsupported/misleading metrics. | Regenerate coverage and verify counts/rates on the root <coverage> element. |
ASCOV007 |
A threshold or tolerance is outside the 0 through 100 range. |
Correct --min-line, --min-branch, --min-patch-line, --min-patch-branch, or --tolerance. |
ASCOV008 |
GitHub step summary could not be written. | Check $GITHUB_STEP_SUMMARY permissions or add --no-github-summary. |
ASCOV009 |
The report output path is unsafe. | Use a dedicated artifact directory, not a filesystem root or working directory. |
ASCOV010 |
The command could not run or read git diff for changed-line coverage, or the diff exceeded 20 MiB. |
Fetch the diff base, pass a valid local commit/ref to --diff-base, or reduce the comparison size. In GitHub pull request workflows using --diff-base HEAD^1, set actions/checkout to fetch-depth: 2; the default depth is 1 and does not include HEAD^1. |
ASCOV011 |
A patch threshold was set without a patch diff source. | Pass exactly one of --diff-base, --diff-file, or --diff-stdin. |
ASCOV012 |
Multiple patch diff sources were set. | Keep only one of --diff-base, --diff-file, or --diff-stdin. |
ASCOV013 |
An external diff file/stdin artifact is missing, unreadable, or larger than 20 MiB. | Regenerate the unified diff artifact or pass a smaller diff. |
ASCOV014 |
--diff-stdin was requested from an interactive terminal. |
Pipe or redirect unified diff text, or use --diff-file. |
ASCOV015 |
A non-empty external diff artifact is not unified diff text. | Download the diff with Accept: application/vnd.github.diff, fix the producer, or use --diff-base. |
ASCOV016 |
Patch source metadata is invalid. | Correct --diff-label or --repository-root. |
ASCOV017 |
--patch-line-mode is not measurable or codecov. |
Use measurable or codecov. |
ASCOV018 |
--patch-line-mode was supplied without a patch source. |
Pass exactly one patch source or remove --patch-line-mode. |
ASCOV019 |
A coverage-gate report artifact could not be created, replaced, or removed. | Check the named artifact path, permissions, and dedicated output directory, then rerun the gate. |
ASCOV020 |
The gate ran successfully and coverage is below threshold. | Raise coverage or lower the threshold intentionally in source control. |
appsurface export
Export a general AppSurface or RazorWire application through the product-facing CLI.
appsurface export --mode hybrid \
--public-origin https://www.example.com \
--live-origin https://api.example.com \
--project ./src/MyApp/MyApp.csproj
The command shares the RazorWire export engine and accepts the same source choices as razorwire export: exactly one of --url, --project, or --dll, plus --framework, --app-args, and --no-build for launched apps. --public-origin rewrites same-origin canonical metadata to the public static host; it does not change crawl routing or app links. --mode hybrid by itself preserves application-style URLs and can support same-origin backend passthrough for RazorWire endpoints, including lazy anti-forgery token refresh. Hybrid still fails missing browser-delivered static assets with RazorWire RWEXPORT003 diagnostics; fix missing CSS, image, script, stylesheet, module preload, icon, font, and asset-shaped preload/prefetch references instead of using hybrid as an asset-ignore mode. Exporter-managed artifact fetches handle redirects inside the shared engine before response content is read or written: same-origin redirects are allowed only when they stay inside the configured export origin and app path, while invalid redirects, redirect loops, and cross-origin or cross-path artifact redirects fail with RazorWire RWEXPORT008. Adding --live-origin enables split-origin rewriting for RazorWire-managed live surfaces. --hybrid-credentials auto is the default and includes credentials for managed live calls when a live origin is configured; omit is an advanced escape hatch for anonymous split-origin live endpoints. See the RazorWire Hybrid Hosting With Cloud Run guide for the local split-origin proof, Cloud Run live-origin recipe, CORS setup, and first-interaction cold-start tradeoff.
Static website deployment extras follow the same export boundary as razorwire export: seeds are app routes, exporter-owned provider artifacts such as _redirects are generated by the exporter, and opaque files such as CNAME belong in the deployment publish root through --publish-root-extras ./deploy/export-extras.yml. The manifest is explicit single-file copy only and fails with RazorWire RWEXPORT007 when an extra is malformed, symlinked, reserved, collides with generated output, or targets an existing file. See the RazorWire CLI Static website deployment extras guidance for the provider table, GitHub Pages CNAME flow, and migration examples.
appsurface docs
Preview AppSurface Docs for a repository checkout.
appsurface docs --repo .
Options:
--repo,-r: Repository root to preview. Defaults to the current directory.--urls,-u: Explicit host URL binding, such ashttp://127.0.0.1:5189.--port,-p: Localhost-only AppSurface Web port shortcut forwarded to the AppSurface Docs host.--all-hosts: Binds--portpreviews to localhost and the all-hosts wildcard. Use this only when LAN, container, or other non-loopback preview access is intentional.--strict: EnablesAppSurfaceDocs:Harvest:FailOnFailure=true, which fails startup when every configured harvester fails.--route-root: Route-family root for version and archive routes.--docs-root: Live docs preview root.--public-origin: Public origin used for absolute canonical metadata, such ashttps://docs.example.com. Use an absolutehttp://orhttps://origin only, with no path, query, or fragment. Do not include the docs route path. When unset, canonical metadata remains app-relative and app routes do not change.--environment,-e: Host environment forwarded to the AppSurface Docs host. Defaults toDevelopmentso the AppSurface Web deterministic per-workspace localhost URL is used when no endpoint is configured.--startup-timeout-seconds: Seconds to wait for the web host to start before failing fast. Defaults to10; use0to disable while investigating intentional pre-bind delays.
appsurface docs preview is an alias for the same behavior, kept so the old deferred shape maps cleanly to the new AppSurface command family.
When no endpoint is configured, the command runs the host in Development from the selected repository root and chooses the same stable localhost port for that repository or worktree. The CLI keeps routine ASP.NET Core lifecycle logs quiet, prints the resolved docs URL after Kestrel is listening, and then attempts to open that page in the system browser. If browser launch fails, the preview keeps running and reports the URL to open manually. Pass --port, --urls, --environment Production, or endpoint settings such as ASPNETCORE_URLS, HTTP/HTTPS ports, or Kestrel endpoints when you intentionally want to bypass that local preview default. --port 5189 binds http://localhost:5189; add --all-hosts only when you intentionally want the wildcard binding http://localhost:5189;http://*:5189, which can expose the preview host beyond the local machine.
Packaged .NET tools usually do not carry ASP.NET Core static web asset manifests. The AppSurface CLI disables static web asset manifest loading for the preview host and relies on AppSurface Docs and RazorWire embedded asset fallbacks instead, so a global or local tool install stays self-contained.
appsurface docs export
Export AppSurface Docs for a repository checkout to static files.
appsurface docs export --repo . --output ./dist/docs --mode cdn --strict
Use cdn when the output folder will be uploaded to GitHub Pages, Netlify, S3, or a plain CDN. Use hybrid only when the exported pages remain behind app-aware routing or live RazorWire frames, forms, streams, or islands. Missing browser assets are never live-route escapes: a CSS reference such as url('/img/map-image.png'), an image path with the wrong casing, or a forgotten script file fails with RWEXPORT003 until the asset is copied, corrected, externalized, or removed.
Options:
--repo,-r: Repository root to harvest. Defaults to the current directory.--output,-o: Output directory for exported static docs. Defaults todist/docs; the directory must be missing or empty before export starts. CI should pass this explicitly and create a fresh output location per run.--mode,-m: Export mode.cdnis the default and validates plus rewrites managed URLs for plain static hosts. Usehybridonly when the output still sits behind application-aware routing. Hybrid tolerates missing live/page routes but still validates browser-delivered static assets.--redirects: Redirect alias materialization strategy.htmlis the default for GitHub Pages and generic static hosts; it writes tiny alias HTML fallback files. Use--mode cdn --redirects netlifyfor Netlify-compatible CDN publishing; export writes one root_redirectsfile with exact301!rules and does not emit alias HTML files. Netlify export rejects self-redirects and conflicting same-source rules after provider path encoding.--redirects netlifyis rejected with--mode hybrid.--seeds: Optional path to a seed-route file. This is long-only because-rmeans--repoin AppSurface CLI commands.--strict: EnablesAppSurfaceDocs:Harvest:FailOnFailure=true, which fails startup when every configured harvester fails. This is separate from--mode cdn, which validates the emitted static artifact and preservesRWEXPORT00xdiagnostics.--route-root: Route-family root for version and archive routes.--docs-root: Live docs root. When--seedsis omitted, export seeds/and this resolved docs root,/docsby default.--public-origin: Public origin used for absolute canonical metadata in exported pages, such ashttps://docs.example.com. Use an absolutehttp://orhttps://origin only, with no path, query, fragment, or userinfo. The export host still crawls loopback internally; this option keeps public canonical links from using that private listener. When unset, canonical metadata remains app-relative and app routes do not change.--live-origin: Optional live origin for split-origin hybrid docs export, such ashttps://api.example.com. Use an absolutehttp://orhttps://origin only, with no path, query, fragment, or userinfo.--hybrid-credentials: Credential behavior for RazorWire-managed live calls in split-origin hybrid export:auto(default),include, oromit.autoincludes credentials when--live-originis set.--environment,-e: Host environment forwarded to the AppSurface Docs host. Defaults toProductionfor export.--startup-timeout-seconds: Seconds to wait for the in-process AppSurface Docs host to start before failing fast. Defaults to10; use0to disable while investigating intentional pre-bind delays.
Export does not expose --port, --urls, or --all-hosts. It binds http://127.0.0.1:0 internally, resolves the actual Kestrel listener, crawls that URL, then stops the host. Before crawling, it reads the AppSurface Docs route manifest from the in-process host, registers every public canonical docs route as an export seed, registers redirect aliases for source-shaped Markdown URLs and declared aliases, and writes .appsurface-docs-route-manifest.json into the export root. After all final files are materialized, export writes .appsurface-docs-release-manifest.json, hashes it, and prints a copy-ready "releaseManifestSha256": "..." catalog snippet. This keeps unlinked-but-public docs pages exportable, gives each alias a proven canonical target before the selected redirect strategy materializes it, lets exact version archives preserve the route identity that existed when the release was captured, and gives the runtime a catalog-pinned integrity proof for mounted archive HTML, JavaScript, CSS, SVG, and search payloads. Export fails with ASDOCSARCHIVE005 when unsupported hidden files such as .nojekyll or .well-known/... are present, so export exact releases to a clean directory before copying the pin. RazorWire RWEXPORT00x diagnostics come from the shared export engine: for RWEXPORT003, add/copy the missing browser asset, correct path casing, make the URL external/data/hash-only when appropriate, or remove the reference; for RWEXPORT008, keep exporter-managed artifact redirects on the same scheme, host, port, and app path, or model the destination as an external reference instead of a static artifact; for RWEXPORT009, remove symlinks, junctions, reparse points, or lexical escapes from the generated output tree before retrying. Do not hand-author _redirects inside the export output; use --redirects netlify so the exporter can validate and own that provider file. Use the generic razorwire export command when exporting arbitrary RazorWire apps via --url, --project, or --dll; use appsurface docs export when AppSurface owns the AppSurface Docs repository host.
Generated docs export artifacts use the RazorWire generated export artifact boundary. The guard covers .appsurface-docs-route-manifest.json, exported HTML/CSS/assets, docs partials, redirect alias HTML, _redirects, and .appsurface-docs-release-manifest.json. RWEXPORT009 is a local filesystem boundary failure; RWEXPORT008 is still reserved for unsafe HTTP redirects while fetching exporter-managed artifacts.
For split-origin AppSurface Docs publishing, keep --public-origin on the static docs origin and add --live-origin for RazorWire-managed live calls:
appsurface docs export \
--repo . \
--output ./dist/docs \
--mode hybrid \
--public-origin https://docs.example.com \
--live-origin https://api.example.com
The RazorWire Hybrid Hosting With Cloud Run guide explains the local proof path, Cloud Run live-origin deployment, CORS credentials, lazy anti-forgery refresh, and first-interaction cold starts.
appsurface docs export intentionally does not expose --publish-root-extras. Exact AppSurface Docs archives are immutable release artifacts: .appsurface-docs-route-manifest.json and .appsurface-docs-release-manifest.json describe the files that belong to the archive, and deployment-owned files such as CNAME, .nojekyll, or /.well-known/security.txt must live in the surrounding publish root outside the exact release tree. Do not place opaque extras inside immutable exact release archives unless a future archive contract explicitly supports them. If a future docs export path wires extras by mistake, it should reject them before host startup with RazorWire RWEXPORT007 [release-archive-incompatible]. For raw provider files, do not copy /_redirects or /_headers; use --redirects netlify for Netlify redirects and wait for structured headers support instead of raw copy-through.
appsurface docs verify-archive
Verify one exact release tree from a version catalog without starting the docs web host.
appsurface docs verify-archive --catalog ./docs-versions.json --version 1.2.3
appsurface docs verify-archive --catalog ./docs-versions.json --version 1.2.3 --trusted-release-root ./published-docs
Options:
--catalog: Path to the AppSurface Docs version catalog JSON file.--version: Exact version identifier to verify.--trusted-release-root: Trusted release root used to resolveexactTreePathentries. When omitted, paths resolve the same way as runtime defaults: relative to the catalog directory.
The command loads the catalog, resolves the selected exactTreePath, and runs the same release archive verification used at runtime. Pass --trusted-release-root when the deployment sets AppSurfaceDocs:Versioning:TrustedReleaseRootPath; otherwise the local verifier may inspect a different relative tree than the host would mount. It exits nonzero when the version is missing, lacks a releaseManifestSha256 pin, has a mismatched manifest digest, has missing or changed files, or contains handler-servable files not covered by the manifest. The catalog pin proves local archive integrity relative to trusted host configuration; it is not a signature or build provenance attestation. For stable AppSurface releases, run this verifier before ./eng/release check --docs-catalog ... or ./eng/release publish --docs-catalog ...; the release tool then confirms the same catalog entry is recorded in release evidence before stable publishing can continue.
Migration map for repo-owned AppSurface Docs export:
| Old path | New path |
|---|---|
razorwire export --project Web/ForgeTrust.AppSurface.Docs.Standalone/... |
appsurface docs export --repo . |
AppSurfaceDocs__Harvest__FailOnFailure=true |
--strict |
--mode cdn |
--mode cdn |
--seeds <file> |
--seeds <file> |
--output <dir> |
--output <dir> |
AppSurfaceDocs__Routing__PublicOrigin=https://docs.example.com |
--public-origin https://docs.example.com |
--project, --dll, --url, --app-args, --no-build, --framework |
use appsurface export for arbitrary app export |
Development
Run the tool from source while developing:
dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- docs --repo .
Use --strict for CI-like validation when an all-failed harvest should stop the preview before the host begins serving.
Run the export command from source while developing:
dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- docs export --repo . --output ./dist/docs --mode cdn --strict