Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Changes

- **`verify` stops when it has no base, and a deleted manifest is a removed gate.** With neither `--base` nor `--no-base`, a run whose detection found nothing (a default branch other than `main` or `master` with no `origin/HEAD`, or a repository with no remote) read no committed change, published that empty change set as `complete` and granted merge: a pull request deleting `.github/workflows/agents-shipgate.yml` on a `develop` repository exited 0 as `mergeable`, and exited 20 as `blocked` once `--base origin/develop` was named. Detection keeps every base it found before and otherwise follows `check`'s ladder (#649): a default branch already at the head is an empty comparison, and a repository with no remote uses its local `main` or `master`. When nothing resolves, the run exits 2 with `base_status: ref_missing`, a `diff_status` of `unavailable`/`refs_missing` whose detail begins `base_unresolved`, a `fetch_base` next action naming `--base` and `--no-base`, and every permission denied. A change deleting the configured manifest that its merge base (or `HEAD`, for a working-tree run without a base) holds is a human `stop` naming the removal, exit 2, instead of the `configure` and `init --write` route; a missing manifest whose base cannot be resolved fails closed as above, one whose base holds it but cannot be compared (a shallow merge base), or whose named `--base` is not available locally (the Action's `diff_base: target` without `fetch-depth: 0`), fails closed with that diff-input `fetch_base` stop in both CI modes, and a repository that never had one keeps the setup route (with an unavailable named base, only where complete history, neither shallow nor grafted, shows the manifest never existed; a depth-1 checkout with an unfetched `--base` therefore stops in advisory mode too, where it used to exit 0 on the host route). `SHIP-VERIFY-CI-GATE-REMOVED` resolves changed paths against the evaluated tree's root instead of the manifest's directory, so `apps/agent/shipgate.yaml` no longer turns a comment in the root workflow into a critical blocker, nor lets a nested copy hide a root deletion. Every Git archive packs the commit's tree rather than its history, so a depth-1 checkout archives `--head` instead of failing an integrity check that agent mode routed to editing `shipgate.yaml`. `check` leaves verify's default reports directory out of a working-tree change set when it holds only uncommitted Shipgate artifacts, so a check, verify, check loop no longer counts the reports as changed files. **A run that used to report `mergeable` with no base now exits 2**: pass `--base` (in the Action, `diff_base: target` with `fetch-depth: 0`, or `base_ref`) or `--no-base`. No schema, contract or check ID moves. See the `verify stops without a base` migration note in `STABILITY.md`. (#1023)
- **`attest`, `org bundle` and the Action validate the verification receipt of current verify output.** All three gated receipt validation on version literals the engine had stopped emitting (verifier `0.5`–`0.7`, `shipgate.verify_run/v3`). On every completed `1.x` verify run, `attest --from agents-shipgate-reports/verifier.json` and the Action's `attestation: true` step crashed with a traceback and exit 1, `org bundle` published a rewritten `receipt_id` or `decision_id` with exit 0, and the Action exported empty `request_id`, `receipt_id`, `decision_id` and `artifact_set_id` beside a `verification_receipt_json` nothing had checked. One predicate, `requires_verification_receipt` in `agents_shipgate.schemas.verification_identity`, now decides for all three: it names the verifier versions written before receipts (`0.1`–`0.4`) and reads every other version, newer ones included, as owing a receipt whenever the verifier states an outcome; the Action, which also publishes `report.json`'s decision, owes one whenever `verifier.json` names a `report.json` as its own, and publishes nothing from a report it does not name (the manifest-free host route leaves an earlier run's in place). A `0.1`–`0.4` label on a payload carrying a receipt-era identity field (`request_id`, `decision_id` and the four others verifier `0.5` added) is not believed. A failed or not-run execution that states none (a missing manifest, the manifest-free host route) writes no receipt, and still publishes, with empty identities. A receipt that is present is always validated: its content addressing, every artifact it binds and the request and decision `verifier.json` names. A missing, tampered or mismatched one exits `3` in `attest` and `org bundle` with one line naming the receipt (plus the agent-mode `next_action` line), and fails the Action's output step. `org bundle` also refuses an `attestation.json` whose receipt identity is not the validated receipt's, or that names one when none was validated. The Action's `verification_receipt_json` is empty unless the receipt validated. No schema version changes. See the [migration note](STABILITY.md#receipt-predicate-1022). (#1022)
- **An OpenAPI operation behind a path-item `$ref` is read, and one that cannot be read is named.** The loader skipped every key of a path item that was not an HTTP method, so `/accounts/{id}: {$ref: '#/components/pathItems/Account'}` contributed nothing: its `deleteAccount` was absent from `report.json` and a strict scan passed. A `$ref` to another path item in the same document is now followed (chains included, with the fields written beside the `$ref` read first), and each operation's `source_pointer` and line name where it is written, such as `/components/pathItems/Account/delete`. A path item whose `$ref` leaves the document (a split spec's `./paths/users.yaml`, which is reported and never opened), names nothing, or loops is a source warning and an `adapter_parse` row (`unresolved_path_item_ref`) in `surface_exclusions`, so the scan no longer passes; a referenced operation replaced by one of the same method beside the `$ref` is one too (`shadowed_path_item_operation`). Schema `$ref`s the document resolves no longer warn: the resolver also descends `additionalProperties`, `patternProperties`, `not`, `prefixItems` and list-form `items`, follows a schema that is only a reference, and an unresolved reference is reported once per target rather than once per tool, so four read operations sharing an `additionalProperties` reference no longer read as `insufficient_evidence`. Unquoted YAML status codes (`200:`) select the lowest 2xx response as the output schema instead of the first response listed, and the DELETE proof's pure-Python YAML parse runs only when a document has a DELETE, stopping once the document is past its node budget. **Tool inventories can grow** where a path-item `$ref` hid operations, and a verdict that rested on the hidden part can move from `passed` to `review_required` or `blocked`; one held back only by false unresolved-reference warnings can move to `passed`. The DELETE proof still refuses a path item written as a `$ref`. (#1021)
- `diff --application` reads bounded local factories returning OpenAI Agents SDK and Google ADK tool lists. It keeps the returned members and their construction locations, and reports unread constructor identity, import effects, escaping values, or unresolved callers as coverage gaps rather than treating them as established wiring. This is static source analysis; it does not execute a factory or establish runtime enforcement. (#874, #964)
Expand Down
93 changes: 91 additions & 2 deletions STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,67 @@ the Action tag) for reproducible CI.

---

<a id="verify-base-unresolved-1023"></a>

## Migration Note: Unreleased — `verify` stops without a base, and a deleted manifest is a removed gate (#1023)

**What was wrong.** With neither `--base` nor `--no-base`, `verify` tried
`origin/HEAD`, `origin/main` and `origin/master` only. Where none existed — a
default branch named `develop` with no `origin/HEAD`, or a repository with no
remote — it read no committed change, published that empty change set as
`complete` and granted merge: a pull request deleting
`.github/workflows/agents-shipgate.yml` exited 0 as `mergeable`, while the same
run with `--base origin/develop` exited 20 as `blocked`. A change deleting
`shipgate.yaml` was sent to `configure` and then `init --write`, and an advisory
run without a base exited 0. `SHIP-VERIFY-CI-GATE-REMOVED` looked for changed
workflow paths beside the manifest rather than at the repository root, so
`apps/agent/shipgate.yaml` turned a comment in the root workflow into a
critical blocker. A depth-1 checkout could not archive `--head`, and agent mode
routed the Git integrity error to editing `shipgate.yaml`. `check` counted the
reports `verify` had just written as changed files.

**What changes.**

- When `--base` is omitted and `--no-base` is not given, `verify` keeps every
base it chose before. Where it found none, it now uses `check`'s ladder
(#649): a default branch already at the head is an empty comparison, and a
repository with no remote uses its local `main` or `master`. When nothing
resolves, the run exits `2` with `execution: "failed"`,
`merge_verdict: "unknown"`, `base_status: "ref_missing"`, a `diff_status` of
`unavailable`/`refs_missing` whose `detail` begins `base_unresolved`, a
`fetch_base` next action and every permission denied. The headline and
remediation name `--base` and `--no-base`.
- When the configured manifest is missing and the compared commit holds it —
the merge base with the base, or `HEAD` for a working-tree run without one —
the run is a human `stop` that names the removal, exits `2`, and offers no
`configure` or `initialize` action. A missing manifest whose base cannot be
resolved fails closed as above. One whose base holds it but whose merge base
cannot be read (a shallow checkout), or whose named `--base` is not
available locally, fails closed with that diff-input stop (`archive_failed`
or `ref_missing`, `fetch_base`) in both CI modes. A repository that never
had the manifest keeps the setup and host routes; with a named base that is
not available, only where complete history from the head (not shallow, no
`info/grafts`) shows the manifest never existed. A depth-1 checkout,
manifest-free or not, therefore stops for the base in both modes, where
advisory used to exit 0 on the host route.
- `SHIP-VERIFY-CI-GATE-REMOVED` resolves changed paths against the root of the
evaluated tree, derived from the manifest's repository-relative identity.
- Every Git archive packs the commit's tree rather than its history, so
`--head` works in a depth-1 checkout.
- `check` leaves the default reports directory out of a working-tree change
set when it holds only uncommitted Shipgate artifacts, as `verify` does.

**What does not change.** No schema, contract or check ID moves. An explicit
`--base`, `--no-base`, and every run whose detection already found a base
behave as before.

**Who must act.** A pipeline whose default branch is not `main` or `master`
passes `--base` (in the Action, `diff_base: target` with `fetch-depth: 0`, or
`base_ref`), or `--no-base` to verify without a base. A manifest removal now
needs a human to approve it.

---

<a id="receipt-predicate-1022"></a>

## Migration Note: Unreleased — `attest`, `org bundle` and the Action validate the verification receipt (#1022)
Expand Down Expand Up @@ -5319,6 +5380,17 @@ CI. If the requested base ref or PR diff context is unavailable, verify records
base manifest or base scan is unavailable, verify records `base_status`, disables
diff enrichment, and leaves the head release decision and exit code unchanged.

With neither `--base` nor `--no-base`, verify detects the base: `origin/HEAD`,
`origin/main` or `origin/master` when one points at a different commit than the
head; otherwise, as `check` does, a default branch already at the head (an
empty comparison) or, in a repository with no remote, a local `main` or
`master`. When nothing resolves, verify runs no scan and exits 2 with
`base_status: "ref_missing"`, a `diff_status` of `unavailable`/`refs_missing`
whose `detail` begins `base_unresolved`, a `fetch_base` next action naming
`--base` and `--no-base`, and every permission denied; it never publishes an
unread change set as complete
([migration note](#verify-base-unresolved-1023)).

Before any trigger-skip can return success, non-preview `verify` also requires
the resolved `--config` path to exist. A missing config is a configuration
failure, not a docs-only or no-trigger success: verify writes `verifier.json`,
Expand All @@ -5331,6 +5403,19 @@ failure, not a docs-only or no-trigger success: verify writes `verifier.json`,
fix the config path or run `agents-shipgate verify --preview --json` before
initializing.

That route is for a manifest that never existed. Before taking it, verify
resolves the base as above and asks whether the compared commit — the merge
base with the base, or `HEAD` for a working-tree run without one — holds the
configured manifest. If it does, the change removes the gate: verify exits 2
with `control.state: "human_review_required"`, a human `stop` naming the
removal, and no `configure` or `initialize` action. If base detection finds
nothing, verify fails closed with the base-unresolved stop, and if the base
holds the manifest but its merge base cannot be read, or a named `--base` is
not available locally, with the diff-input stop, instead of routing to setup.
A named base that is not available keeps the setup route only where complete
history from the head (not shallow, no `info/grafts`) shows the manifest never
existed.

The head scan writes `report.md`, `report.json`, `report.sarif`, `packet.json`,
`verifier.json`, `verify-run.json`, `agent-handoff.json`, `pr-comment.md`,
`verification-plan.json`, `verification-unit-result.json`,
Expand Down Expand Up @@ -5400,8 +5485,12 @@ fields a consumer may read:
merge base which does exist, and deepening restores it; the second is two
roots with no common ancestor, which no fetch can create — `fetch_repairable`
is the field to branch on. `detail` is a bounded, path-redacted excerpt of
Git's own diagnostic; `remediation` names the repair; `fetch_repairable`
says whether making refs or objects available locally can fix it.
Git's own diagnostic; when base detection found nothing it instead begins
with the token `base_unresolved`, which is what tells that `refs_missing`
stop apart from a head ref missing locally
([migration note](#verify-base-unresolved-1023)). `remediation` names the
repair; `fetch_repairable` says whether making refs or objects available
locally can fix it.
**`"complete"` is the only value that licenses reading a negative `trigger`
result.** Anything else means the evidence the verdict would rest on was
missing — it is never evidence that a PR is unrelated to agent capabilities.
Expand Down
22 changes: 20 additions & 2 deletions src/agents_shipgate/checks/verify_ci_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

from __future__ import annotations

from pathlib import Path
import os
from pathlib import Path, PurePosixPath

from agents_shipgate.checks._verify_common import (
changed_files,
Expand Down Expand Up @@ -62,10 +63,27 @@ def run(context: ScanContext) -> list[Finding]:


def _workspace_root(context: ScanContext) -> Path:
"""Best-effort workspace root: the directory containing shipgate.yaml."""
"""The root of the evaluated tree, which changed paths are relative to.

Changed paths are repository-relative, and so is the manifest identity the
verifier binds (``configured_manifest_path``). Stripping that identity's
components from the physical manifest path names the root of whichever
tree was evaluated: the checkout, or a committed head's archive. The
manifest's own directory is that root only when the manifest sits there;
for ``apps/agent/shipgate.yaml`` a comment in the root workflow read as a
removed gate, and a nested copy of the workflow hid a real root deletion
(#1023). Without a bound identity the manifest's directory is kept.
"""
config_path = getattr(context, "config_path", None)
if config_path is None:
return Path.cwd()
verification = getattr(context, "verification", None)
logical = getattr(verification, "configured_manifest_path", None)
if logical:
physical = Path(os.path.abspath(config_path))
depth = len(PurePosixPath(logical).parts)
if 0 < depth < len(physical.parts):
return physical.parents[depth - 1]
return Path(config_path).resolve().parent


Expand Down
55 changes: 54 additions & 1 deletion src/agents_shipgate/cli/agent_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,48 @@ def default_base() -> str:
return requested, None, "", merge_base


def _reports_exclusion(
workspace: Path,
*,
requested_workspace: Path,
comparison_ref: str,
) -> Path | None:
"""The reports directory ``verify`` leaves out, left out here too (#1023).

``verify`` writes its default reports beneath the requested workspace and
excludes that directory from every working-tree read, so a check, verify,
check loop counted the reports it had just written as the developer's
change. The same directory is left out here, through the same
:func:`worktree_exclusion`, and only where
:func:`classify_output_directory` shows that it holds nothing but
uncommitted Shipgate artifacts. Anything else Git would report there is
content, and keeping it costs only a longer change set.
"""

from agents_shipgate.cli.current_workspace import (
classify_output_directory,
default_reports_dir,
worktree_exclusion,
)
from agents_shipgate.cli.verify.git import commit_sha

try:
reports = default_reports_dir(requested_workspace)
if not os.path.lexists(reports):
return None
compared = commit_sha(workspace, comparison_ref)
if (
classify_output_directory(
workspace, reports, compared=[compared] if compared else []
)
is not None
):
return None
return worktree_exclusion(workspace, reports)
except Exception: # noqa: BLE001 - no exclusion can only surface more paths.
return None


def git_boundary_change_set(
*,
workspace: Path,
Expand Down Expand Up @@ -714,7 +756,18 @@ def git_boundary_change_set(
)
try:
changed_paths, diff_text = _git_diff_context(
revspec, cwd=workspace, comparison_ref=comparison_ref
revspec,
cwd=workspace,
comparison_ref=comparison_ref,
exclude=(
None
if revspec
else _reports_exclusion(
workspace,
requested_workspace=requested_workspace,
comparison_ref=comparison_ref,
)
),
)
except BinaryCapabilityDiffError as exc:
return BoundaryChangeSet(
Expand Down
3 changes: 2 additions & 1 deletion src/agents_shipgate/cli/verify/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ def verify(
"omitted, verify auto-detects the default branch (origin/HEAD, "
"origin/main, origin/master) if it points at a different commit "
"than the head. Local main/master are used only when passed "
"explicitly; --no-base disables auto-detection."
"explicitly or when the repository has no remote. When no base "
"is found, verify exits 2; --no-base disables auto-detection."
),
),
no_base: bool = typer.Option(
Expand Down
Loading
Loading