diff --git a/CHANGELOG.md b/CHANGELOG.md
index 668e3268..580f6c17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/STABILITY.md b/STABILITY.md
index 49be617e..ec225c48 100644
--- a/STABILITY.md
+++ b/STABILITY.md
@@ -347,6 +347,67 @@ the Action tag) for reproducible CI.
---
+
+
+## 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.
+
+---
+
## Migration Note: Unreleased — `attest`, `org bundle` and the Action validate the verification receipt (#1022)
@@ -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`,
@@ -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`,
@@ -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.
diff --git a/src/agents_shipgate/checks/verify_ci_gate.py b/src/agents_shipgate/checks/verify_ci_gate.py
index 72627cd5..8926de64 100644
--- a/src/agents_shipgate/checks/verify_ci_gate.py
+++ b/src/agents_shipgate/checks/verify_ci_gate.py
@@ -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,
@@ -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
diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py
index 20b370e5..4617c75d 100644
--- a/src/agents_shipgate/cli/agent_result.py
+++ b/src/agents_shipgate/cli/agent_result.py
@@ -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,
@@ -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(
diff --git a/src/agents_shipgate/cli/verify/command.py b/src/agents_shipgate/cli/verify/command.py
index a9b98049..2cbfbee9 100644
--- a/src/agents_shipgate/cli/verify/command.py
+++ b/src/agents_shipgate/cli/verify/command.py
@@ -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(
diff --git a/src/agents_shipgate/cli/verify/git.py b/src/agents_shipgate/cli/verify/git.py
index 43b80c2f..3bc56396 100644
--- a/src/agents_shipgate/cli/verify/git.py
+++ b/src/agents_shipgate/cli/verify/git.py
@@ -498,7 +498,9 @@ def detect_default_base(
remote at all**, there is no such authority and the local branch is
the only base in existence. Refusing there would leave `check` with
nothing to compare on a repository that was simply never pushed
- (#649). Off by default, so `verify` keeps its behaviour exactly.
+ (#649). Off by default; `verify` reaches it through
+ :func:`resolve_verify_base` only after the remote-only detection found
+ nothing (#1023).
``allow_equal_head`` keeps an authoritative default at ``head`` eligible
for callers comparing the working tree or an explicitly requested head.
@@ -559,6 +561,52 @@ def detect_default_base_with_notes(
return DefaultBaseDetection(base=selected_base, notes=notes)
+@dataclass(frozen=True)
+class VerifyBaseResolution:
+ """What ``verify`` compares against when ``--base`` is omitted (#1023).
+
+ ``base`` is the ref to diff against. ``at_head`` names a default branch
+ that already points at the head: the comparison resolved, and there is
+ nothing committed to compare, so no base scan is spent on it. Both
+ ``None`` means no base exists to compare against at all, which ``verify``
+ refuses rather than publishing an empty diff as a complete one.
+ """
+
+ base: str | None
+ notes: list[str]
+ at_head: str | None = None
+
+ @property
+ def resolved(self) -> bool:
+ return self.base is not None or self.at_head is not None
+
+
+def resolve_verify_base(workspace: Path, head: str = "HEAD") -> VerifyBaseResolution:
+ """``check``'s base ladder (#649), keeping every base ``verify`` already chose.
+
+ The remote-only detection runs first and wins whenever it finds a base, so
+ a run that had one keeps exactly that one. Only when it finds none is the
+ ladder ``check`` uses consulted: a default branch at the head is a
+ resolved, empty comparison, and a repository with no remote at all falls
+ back to its local ``main`` or ``master``. Anything else is unresolved.
+ """
+
+ detection = detect_default_base_with_notes(workspace, head)
+ if detection.base is not None:
+ return VerifyBaseResolution(base=detection.base, notes=detection.notes)
+ ladder = detect_default_base_with_notes(
+ workspace,
+ head,
+ allow_local_when_no_remote=True,
+ allow_equal_head=True,
+ )
+ if ladder.base is None:
+ return VerifyBaseResolution(base=None, notes=ladder.notes)
+ if commit_sha(workspace, ladder.base) == commit_sha(workspace, head):
+ return VerifyBaseResolution(base=None, notes=detection.notes, at_head=ladder.base)
+ return VerifyBaseResolution(base=ladder.base, notes=ladder.notes)
+
+
def _has_configured_remote(workspace: Path) -> bool:
"""Whether this checkout has any remote to be stale against."""
@@ -1062,6 +1110,69 @@ def path_present_at_ref(workspace: Path, ref: str, path: Path) -> bool | None:
return False
+def path_ever_present_from(workspace: Path, ref: str, path: Path) -> bool | None:
+ """Whether any commit reachable from ``ref`` ever held exactly ``path``.
+
+ ``False`` only when complete history proves it never did. A shallow
+ checkout cannot see what came before its boundary: ``rev-list`` there
+ answers with empty output and exit 0, because the boundary is a root, so
+ the guard is what keeps that silence from reading as "never". A
+ ``$GIT_DIR/info/grafts`` file cuts history the same way while
+ ``--is-shallow-repository`` says ``false``, and Git honours it even with
+ replace objects disabled, so its presence answers ``None`` too, as does a
+ ref or a Git that cannot answer. Every commit that changed the path is
+ considered (``--full-history``), so a side branch that added and later
+ removed it is not simplified away behind a merge.
+ """
+
+ if _history_is_truncated(workspace) is not False:
+ return None
+ if _graft_file_present(workspace) is not False:
+ return None
+ commit = commit_sha(workspace, ref)
+ if commit is None:
+ return None
+ result = _run_git(
+ workspace,
+ [
+ "--literal-pathspecs",
+ "rev-list",
+ "-1",
+ "--full-history",
+ commit,
+ "--",
+ path.as_posix(),
+ ],
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ return bool(result.stdout.strip())
+
+
+def _graft_file_present(workspace: Path) -> bool | None:
+ """Whether the repository carries a ``$GIT_DIR/info/grafts`` file.
+
+ ``None`` when its location cannot be read, which no caller may take as
+ "absent". ``GIT_GRAFT_FILE`` cannot move it: every Git run here drops the
+ caller's ``GIT_*`` environment.
+ """
+
+ result = _run_git(workspace, ["rev-parse", "--git-path", "info/grafts"], check=False)
+ if result.returncode != 0 or not result.stdout.strip():
+ return None
+ graft = Path(result.stdout.strip())
+ if not graft.is_absolute():
+ graft = workspace / graft
+ try:
+ graft.lstat()
+ except FileNotFoundError:
+ return False
+ except OSError:
+ return None
+ return True
+
+
#: The longest link text a tree entry is read for. No platform accepts a
#: longer path, so a longer text is never followed.
_MAX_LINK_TEXT_BYTES = 4096
@@ -2439,9 +2550,10 @@ def archive_tree(
same links. Without a scope every blob is written; that once cost one
``git cat-file`` per file, 1,168 subprocesses and 25 seconds on a 26 MB
repository whose host surface is four files (#686), and blobs are now
- read in a few bounded ``cat-file --batch`` processes instead. A scoped
- archive also packs the tree rather than the commit, so no history is
- walked.
+ read in a few bounded ``cat-file --batch`` processes instead. Every
+ archive packs the tree rather than the commit, so no history is walked:
+ the commit's parents are not part of the snapshot, and a depth-1
+ checkout, which lacks them, archives its head like any other (#1023).
Scoping changes what is *materialized*, never what is *verified*: every
blob written is still checked against its object ID, and the isolated
@@ -2473,7 +2585,7 @@ def archive_tree(
workspace,
commit=commit,
git_dir=git_dir,
- tree=tree if scope is not None else None,
+ tree=tree,
)
with _perf.phase("archive.materialize_scoped"):
gitlinks = _materialize_isolated_tree(
@@ -2657,8 +2769,8 @@ def _copy_verified_commit_graph(
raise ConfigError(f"Copied Git objects failed index validation: {detail}")
# fsck the object the pack actually carries. A tree pack has no commit,
# and asking for one that is not there fails the check for the wrong
- # reason; a tree also has no parents, which is why a scoped archive works
- # on a shallow clone where a commit walk crosses the graft (#683).
+ # reason; a tree also has no parents, which is why an archive works on a
+ # shallow clone where a commit walk crosses the graft (#683, #1023).
with _perf.phase("archive.copy.fsck"):
checked = _run_git_dir(
git_dir,
diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py
index cafb21ea..e817a5d7 100644
--- a/src/agents_shipgate/cli/verify/orchestrator.py
+++ b/src/agents_shipgate/cli/verify/orchestrator.py
@@ -222,6 +222,7 @@
ensure_git_workspace,
git_path,
merge_base_sha,
+ path_ever_present_from,
path_present_at_ref,
read_bytes_at_ref,
read_file_at_ref,
@@ -232,6 +233,7 @@
require_merge_base_sha,
resolve_source_head_identity,
resolve_tree_path_identity,
+ resolve_verify_base,
tree_sha,
working_tree_context,
working_tree_paths,
@@ -283,6 +285,164 @@
_BASE_COMPARISON_FAILURES = frozenset({"ref_missing", "archive_failed", "scan_failed"})
+def _base_unresolved_route() -> tuple[VerifierDiffStatus, str, CodingAgentFetchBaseAction]:
+ """The stop for a base that was asked for and does not exist (#1023).
+
+ Without ``--base`` and without ``--no-base``, a run whose detection found
+ nothing used to read no committed change, publish that empty change set as
+ ``complete`` and grant merge: a pull request deleting the Shipgate workflow
+ on a ``develop`` repository was ``mergeable``. It is refused instead, with
+ the two flags that resolve it. The published vocabulary already has the
+ state (``ref_missing``, ``refs_missing``); ``base_unresolved`` names which
+ one this is, as other diff headlines name their reason.
+ """
+
+ headline = (
+ "No base ref could be detected (base_unresolved), so Shipgate read no "
+ "committed change and reached no verdict. Pass --base [ naming the "
+ "branch this change merges into, or --no-base to verify without a base."
+ )
+ status = VerifierDiffStatus(
+ completeness="unavailable",
+ reason="refs_missing",
+ detail=(
+ "base_unresolved: no base ref could be detected. Detection reads "
+ "origin/HEAD, origin/main and origin/master, and a local main or "
+ "master only in a repository with no remote; none is available here."
+ ),
+ remediation=(
+ "Pass --base ][ naming the branch this change merges into once it "
+ "is available locally (in GitHub Actions, check out with "
+ "fetch-depth: 0 and set the Action's diff_base: target), or pass "
+ "--no-base to verify without a base, which compares no committed "
+ "change."
+ ),
+ fetch_repairable=True,
+ )
+ action = CodingAgentFetchBaseAction(
+ kind="fetch_base",
+ expects=(
+ "A local base ref for this change, passed as --base ][; or "
+ "--no-base to verify without one."
+ ),
+ why=headline,
+ )
+ return status, headline, action
+
+
+@dataclasses.dataclass(frozen=True)
+class _ManifestRemoval:
+ """What the compared side says about a manifest the evaluated tree lacks.
+
+ ``removed``: the comparison commit holds the configured manifest and the
+ evaluated tree does not, so this change removes the gate. ``absent``: the
+ base is shown not to hold one, or complete history shows there never was
+ one, which is setup's question. ``unresolved``: no base exists to ask.
+ ``unknown``: the base cannot show which, because its ref is not here or
+ its merge base cannot be read, and ``failure`` says why.
+ """
+
+ state: str
+ base: str | None = None
+ comparison: str | None = None
+ failure: DiffContext | None = None
+ notes: tuple[str, ...] = ()
+
+
+def _probe_manifest_removal(
+ *,
+ git_root: Path,
+ config_relative: Path,
+ base: str | None,
+ head: str,
+ archive_head: bool,
+ auto_base: bool,
+) -> _ManifestRemoval:
+ """Whether a missing manifest is a removed gate, before any setup route (#1023).
+
+ The setup route (``configure``, then ``init --write``) is right only where
+ no Shipgate gate existed. Deleting a committed ``shipgate.yaml`` reached it
+ too, and without a resolvable base an advisory run then exited 0. The
+ base is resolved exactly as a configured run resolves it, and the
+ comparison is the merge base, as a configured run's is: a manifest the
+ base added after this branch forked is not one this change removes. With
+ no base requested, a working-tree run still compares against ``HEAD``.
+ """
+
+ notes: tuple[str, ...] = ()
+ at_head: str | None = None
+ if base is None and auto_base:
+ resolution = resolve_verify_base(git_root, head)
+ notes = tuple(resolution.notes)
+ if not resolution.resolved:
+ return _ManifestRemoval(state="unresolved", notes=notes)
+ base, at_head = resolution.base, resolution.at_head
+ if base is not None:
+ try:
+ comparison: str | None = require_merge_base_sha(git_root, base, head)
+ except DiffInputError as exc:
+ # A base without the manifest cannot make this a removal. A named
+ # base that is not here at all says nothing either way, so the
+ # setup route keeps its precedence only where complete history
+ # proves the manifest never existed; otherwise this is an unknown
+ # base, refused like any other (#1023 review cycle 1).
+ held = path_present_at_ref(git_root, base, config_relative)
+ if held is False or (
+ held is None
+ and path_ever_present_from(git_root, head, config_relative) is False
+ ):
+ return _ManifestRemoval(state="absent", base=base, notes=notes)
+ return _ManifestRemoval(
+ state="unknown", base=base, failure=exc.context, notes=notes
+ )
+ elif archive_head and at_head is None:
+ # An explicit head with --no-base compares nothing at all.
+ return _ManifestRemoval(state="absent", notes=notes)
+ else:
+ comparison = commit_sha(git_root, head)
+ present = (
+ path_present_at_ref(git_root, comparison, config_relative)
+ if comparison is not None
+ else None
+ )
+ if present is None:
+ return _ManifestRemoval(
+ state="unknown",
+ base=base,
+ failure=DiffContext(
+ completeness="unavailable",
+ reason="git_failed",
+ detail=(
+ "Git could not say whether the compared commit holds "
+ f"{config_relative.as_posix()}."
+ ),
+ ),
+ notes=notes,
+ )
+ if not present:
+ return _ManifestRemoval(state="absent", base=base, notes=notes)
+ if archive_head and path_present_at_ref(git_root, head, config_relative) is not False:
+ # The requested head still holds it; only this checkout lacks it.
+ return _ManifestRemoval(state="absent", base=base, notes=notes)
+ return _ManifestRemoval(state="removed", base=base, comparison=comparison, notes=notes)
+
+
+def _manifest_removal_headline(
+ manifest: str, *, base: str | None, comparison: str
+) -> str:
+ holder = (
+ f"its merge base with {base} ({comparison[:12]})"
+ if base is not None
+ else f"the head commit ({comparison[:12]})"
+ )
+ return (
+ f"Shipgate manifest removed: this change removes {manifest}, which "
+ f"{holder} holds, so the release gate would stop running. A human must "
+ "approve removing Shipgate; do not delete the manifest to get past the "
+ "gate."
+ )
+
+
def _configured_manifest_provenance(
*,
git_root: Path,
@@ -474,6 +634,134 @@ def run_verify(
if openshell_proof_required:
rerun_options.append("--openshell-proof-required")
+ def publish_unscanned(
+ *,
+ base_ref: str | None,
+ base_status: VerifierBaseStatus,
+ diff_status: VerifierDiffStatus,
+ headline: str,
+ action: AgentControlAction,
+ notes: Sequence[str],
+ ) -> tuple[VerifierArtifact, None, int]:
+ """Publish a run that stopped before any scan, on its one route (#1023)."""
+
+ verifier = _build_verifier(
+ git_root=git_root,
+ config_path=config_path,
+ base=base_ref,
+ head=head,
+ changed_files=[],
+ diff_text="",
+ trigger=evaluate(
+ paths=[],
+ diff_text="",
+ manifest_present=config_path.is_file(),
+ user_requested=True,
+ input_status=INPUT_UNAVAILABLE,
+ ),
+ base_status=base_status,
+ base_tree=None,
+ diff_status=diff_status,
+ base_report=None,
+ base_notes=list(notes),
+ report=None,
+ head_status="failed",
+ head_exit_code=2,
+ out_dir=out_dir,
+ manifest_provenance_value=configured_manifest_provenance,
+ ci_mode=ci_mode,
+ headline_override=headline,
+ first_next_action_override=action,
+ worktree=not archive_head,
+ rerun_options=rerun_options,
+ )
+ _remove_scan_artifacts(out_dir)
+ _write_artifacts(
+ verifier,
+ verifier_path,
+ verify_run_path,
+ pr_comment_path,
+ report=None,
+ git_root=git_root,
+ manifest_provenance_value=configured_manifest_provenance,
+ config_path=config_path,
+ config_logical_path=config_relative.as_posix(),
+ baseline_path=baseline_path,
+ policy_pack_paths=policy_pack_paths or [],
+ plugins_enabled=plugins_enabled,
+ no_heuristics=no_heuristics,
+ fail_on=fail_on,
+ pr_comment_style=pr_comment_style,
+ )
+ return verifier, None, 2
+
+ if not config_path.is_file() and ref_exists(git_root, head):
+ # A deleted manifest is a removed gate, never first adoption, and a
+ # base that cannot be read cannot show which of the two this is.
+ removal = _probe_manifest_removal(
+ git_root=git_root,
+ config_relative=config_relative,
+ base=base,
+ head=head,
+ archive_head=archive_head,
+ auto_base=auto_base,
+ )
+ if removal.state == "unresolved":
+ status, headline, action = _base_unresolved_route()
+ return publish_unscanned(
+ base_ref=None,
+ base_status="ref_missing",
+ diff_status=status,
+ headline=headline,
+ action=action,
+ notes=[*removal.notes, status.detail or headline],
+ )
+ if removal.state == "unknown":
+ assert removal.failure is not None
+ failure = removal.failure
+ return publish_unscanned(
+ base_ref=removal.base,
+ base_status=(
+ "ref_missing" if failure.reason == "refs_missing" else "archive_failed"
+ ),
+ diff_status=_diff_status_artifact(failure),
+ headline=_diff_failure_headline(failure) or failure.note,
+ action=_diff_failure_action(
+ failure, expects=f"{removal.base}...{head}"
+ ),
+ notes=[
+ *removal.notes,
+ "Could not establish whether the compared commit holds "
+ f"{config_relative.as_posix()}. {failure.note}",
+ ],
+ )
+ if removal.state == "removed":
+ assert removal.comparison is not None
+ headline = _manifest_removal_headline(
+ config_relative.as_posix(),
+ base=removal.base,
+ comparison=removal.comparison,
+ )
+ return publish_unscanned(
+ base_ref=removal.base,
+ base_status="skipped" if removal.base is not None else "not_requested",
+ diff_status=VerifierDiffStatus(
+ completeness="unavailable",
+ reason="not_attempted",
+ detail=(
+ "verify stopped at the removed manifest; the rest of the "
+ "change was not read."
+ ),
+ remediation=(
+ "A human decides whether Shipgate may be removed. To "
+ "verify the change instead, restore the manifest and rerun."
+ ),
+ ),
+ headline=headline,
+ action=HumanControlAction(kind="stop", why=headline),
+ notes=[*removal.notes, headline],
+ )
+
if not config_path.is_file():
from .host_comparison import compare_host_refs, host_comparison_failure
@@ -690,7 +978,7 @@ def run_verify(
verification_date = commit_date(git_root, head)
if base is None and auto_base:
- detection = detect_default_base_with_notes(git_root, head)
+ detection = resolve_verify_base(git_root, head)
base_notes.extend(detection.notes)
if detection.base is not None:
base = detection.base
@@ -698,6 +986,19 @@ def run_verify(
f"Auto-detected base {detection.base!r} for diff context; "
"pass --base to override or --no-base to disable."
)
+ elif not detection.resolved:
+ # Nothing to compare against is not an empty comparison: the
+ # committed change was never read, so no diff can be published as
+ # complete and no merge granted on it (#1023).
+ status, headline, action = _base_unresolved_route()
+ return publish_unscanned(
+ base_ref=None,
+ base_status="ref_missing",
+ diff_status=status,
+ headline=headline,
+ action=action,
+ notes=[*base_notes, status.detail or headline],
+ )
base_exists = False
effective_worktree_ref = head
@@ -6209,7 +6510,7 @@ def _reject_output_directory_content(
if not archive_head:
bases = [base] if base is not None else []
if base is None and auto_base:
- bases.append(detect_default_base_with_notes(git_root, head).base)
+ bases.append(resolve_verify_base(git_root, head).base)
if manifest_free and head_commit is not None:
bases.append(
detect_default_base(
diff --git a/src/agents_shipgate/triggers.py b/src/agents_shipgate/triggers.py
index aa918be2..68ee5f41 100644
--- a/src/agents_shipgate/triggers.py
+++ b/src/agents_shipgate/triggers.py
@@ -938,6 +938,7 @@ def _git_diff_context(
*,
cwd: Path | None = None,
comparison_ref: str = "HEAD",
+ exclude: Path | None = None,
) -> tuple[list[str], str]:
"""Read changed paths and the unified-diff body from ``git diff``.
@@ -958,6 +959,10 @@ def _git_diff_context(
``cwd`` selects the git working directory; ``None`` uses the
process cwd.
+ ``exclude`` leaves one directory out of a working-tree comparison: the
+ reports directory, so `check` and `verify` agree on what the change is
+ (#1023). A committed range never holds it.
+
Returns ``([paths], diff_text)``.
"""
# Lazy import avoids a module cycle: the verifier orchestrator imports the
@@ -973,7 +978,10 @@ def _git_diff_context(
diff_revspec_context(root, revspec)
if revspec
else working_tree_context(
- root, comparison_ref=comparison_ref, reject_index_hidden=True
+ root,
+ comparison_ref=comparison_ref,
+ exclude=exclude,
+ reject_index_hidden=True,
)
)
diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json
index ce29735c..3299401a 100644
--- a/tests/shard_seconds.json
+++ b/tests/shard_seconds.json
@@ -433,7 +433,8 @@
"tests/test_builder_construction_conditions.py": 3.1,
"tests/test_sdk_agent_clones.py": 73.3,
"tests/test_memory_bank_effects.py": 2.6,
- "tests/test_verification_receipt_consumers.py": 3.7
+ "tests/test_verification_receipt_consumers.py": 3.7,
+ "tests/test_verify_base_and_gate_removal.py": 17.9
},
"measured_at": "cbff1ef0b101",
"measurement": {
@@ -521,6 +522,17 @@
"case_count": 47,
"command": "python -m pytest tests/test_verification_receipt_consumers.py --junitxml=receipt-consumers.xml",
"time_basis": "JUnit testcase time; original full-suite measurements retained"
+ },
+ {
+ "source": "junit",
+ "measured_at": "c4050229 with issue #1023 verify base and gate removal increment",
+ "report_sha256": "0f02fb976c495a678383716b02383c3b9bfc841674317466d04cc6f9794cf6f4",
+ "files": {
+ "tests/test_verify_base_and_gate_removal.py": 17.9
+ },
+ "case_count": 20,
+ "command": "python -m pytest tests/test_verify_base_and_gate_removal.py --junitxml=base-gate.xml",
+ "time_basis": "JUnit testcase time; original full-suite measurements retained"
}
]
}
diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py
index e50b31c2..0719478c 100644
--- a/tests/test_adapter_static_only.py
+++ b/tests/test_adapter_static_only.py
@@ -263,7 +263,7 @@ class AllowedException:
AllowedException(
relative_path="cli/verify/git.py",
surface="attr_call:subprocess.Popen",
- line=3268,
+ line=3380,
snippet=(
"subprocess.Popen(cmd, env=env, stderr=subprocess.PIPE, "
"stdin=subprocess.PIPE if input is not None else "
@@ -286,7 +286,7 @@ class AllowedException:
AllowedException(
relative_path="cli/verify/git.py",
surface="attr_call:subprocess.run",
- line=3677,
+ line=3789,
snippet=(
"subprocess.run(cmd, capture_output=capture_output, check=check, "
"env=env, input=input, stderr=stderr, stdin=stdin, stdout=stdout, "
diff --git a/tests/test_manifest_free_pr_rows.py b/tests/test_manifest_free_pr_rows.py
index 2cff0d0a..28b6132b 100644
--- a/tests/test_manifest_free_pr_rows.py
+++ b/tests/test_manifest_free_pr_rows.py
@@ -318,11 +318,16 @@ def test_a_refusal_that_never_reached_a_base_prints_no_reference_line(s1, tmp_pa
["git", "clone", "-q", "--depth=1", "--branch", "change", s1.as_uri(), str(clone)],
check=True,
)
+ # The base is present, at depth 1, so only its merge base lies past the
+ # graft. A named base that is not here at all, in a checkout whose history
+ # cannot show that no manifest ever existed, is refused before any host
+ # comparison is attempted (#1023 review cycle 1).
+ git(clone, "fetch", "-q", "--depth=1", "origin", "+main:refs/remotes/origin/main")
out = tmp_path / "out"
result = CliRunner().invoke(
app,
- ["verify", "--workspace", str(clone), "--base", "HEAD~1",
+ ["verify", "--workspace", str(clone), "--base", "origin/main",
"--format", "text", "--out", str(out)],
)
diff --git a/tests/test_verify_base_and_gate_removal.py b/tests/test_verify_base_and_gate_removal.py
new file mode 100644
index 00000000..ead77d2a
--- /dev/null
+++ b/tests/test_verify_base_and_gate_removal.py
@@ -0,0 +1,645 @@
+"""#1023: verify fails closed without a base or a manifest, and anchors gate paths.
+
+Five ways `verify` reported a diff it never computed, or misrouted a removed
+gate:
+
+* With no detectable base (a default branch other than main/master, no
+ ``origin/HEAD``), the run read no committed change, published a complete
+ diff and granted merge. It now stops with exit 2, names ``base_unresolved``
+ and offers ``--base`` and ``--no-base``. A repository with no remote uses its
+ local ``main`` the way `check` does (#649).
+* A change that deletes ``shipgate.yaml`` was routed to ``configure`` and then
+ ``init --write``. When the base had the manifest it is now a human stop.
+* ``SHIP-VERIFY-CI-GATE-REMOVED`` joined repository-relative paths onto the
+ manifest's directory, so a nested manifest turned a comment in the root
+ workflow into a removed gate.
+* A depth-1 checkout could not archive ``--head`` (the commit pack walked into
+ the missing parent) and agent mode said to edit ``shipgate.yaml``.
+* `check` counted verify's own untracked reports as changed files.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+from typer.testing import CliRunner
+
+from agents_shipgate.cli.main import app
+
+runner = CliRunner()
+
+#: Git that ignores the caller's configuration, hooks and exported ``GIT_*``.
+_GIT_ENV = {
+ **{key: value for key, value in os.environ.items() if not key.startswith("GIT_")},
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ "GIT_CONFIG_NOSYSTEM": "1",
+}
+
+MANIFEST = """\
+version: "0.1"
+project:
+ name: test
+agent:
+ name: test-agent
+ declared_purpose:
+ - test
+environment:
+ target: local
+tool_sources:
+ - id: tools
+ type: mcp
+ path: tools.json
+"""
+
+WORKFLOW = """\
+name: agents-shipgate
+on: [pull_request]
+jobs:
+ shipgate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+"""
+
+GATE = ".github/workflows/agents-shipgate.yml"
+CHECK_ID = "SHIP-VERIFY-CI-GATE-REMOVED"
+
+
+def _git(cwd: Path, *args: str) -> str:
+ return subprocess.run(
+ [
+ "git",
+ "-c", "user.name=Test",
+ "-c", "user.email=test@example.invalid",
+ "-c", "commit.gpgsign=false",
+ "-c", f"core.hooksPath={os.devnull}",
+ "-c", "protocol.file.allow=always",
+ *args,
+ ],
+ cwd=cwd,
+ check=True,
+ capture_output=True,
+ text=True,
+ env=_GIT_ENV,
+ ).stdout.strip()
+
+
+def _write(path: Path, text: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+
+
+def _project(repo: Path, *, branch: str, manifest_dir: str = "") -> None:
+ repo.mkdir(parents=True)
+ _git(repo, "init", "-q", "-b", branch)
+ root = repo / manifest_dir if manifest_dir else repo
+ _write(root / "shipgate.yaml", MANIFEST)
+ _write(root / "tools.json", '{"tools": []}\n')
+ _write(repo / GATE, WORKFLOW)
+ _write(repo / "README.md", "base\n")
+ _git(repo, "add", "-A")
+ _git(repo, "commit", "-qm", "base")
+
+
+def _commit(repo: Path, message: str) -> str:
+ _git(repo, "add", "-A")
+ _git(repo, "commit", "-qm", message)
+ return _git(repo, "rev-parse", "HEAD")
+
+
+def _ci_clone(origin: Path, destination: Path, sha: str) -> Path:
+ """A CI-style checkout: every branch fetched, no ``origin/HEAD``."""
+
+ destination.mkdir()
+ _git(destination, "init", "-q", "-b", "ci")
+ _git(destination, "remote", "add", "origin", f"file://{origin}")
+ _git(destination, "fetch", "-q", "origin", "+refs/heads/*:refs/remotes/origin/*")
+ _git(destination, "checkout", "-q", "--detach", sha)
+ return destination
+
+
+def _develop_origin(tmp_path: Path) -> tuple[Path, str, str]:
+ """An origin whose default branch is ``develop``, with two PR branches."""
+
+ origin = tmp_path / "origin"
+ _project(origin, branch="develop")
+ _git(origin, "checkout", "-q", "-b", "pr-delete-workflow")
+ (origin / GATE).unlink()
+ workflow_pr = _commit(origin, "delete the gate workflow")
+ _git(origin, "checkout", "-q", "develop")
+ _git(origin, "checkout", "-q", "-b", "pr-delete-manifest")
+ (origin / "shipgate.yaml").unlink()
+ manifest_pr = _commit(origin, "delete the manifest")
+ _git(origin, "checkout", "-q", "develop")
+ return origin, workflow_pr, manifest_pr
+
+
+def _verify(workspace: Path, *args: str, out: Path, config: str = "shipgate.yaml"):
+ result = runner.invoke(
+ app,
+ [
+ "verify",
+ "--workspace", str(workspace),
+ "--config", config,
+ "--out", str(out),
+ "--format", "json",
+ *args,
+ ],
+ )
+ return result, json.loads(result.stdout)
+
+
+def _finding_ids(out: Path) -> list[str]:
+ report = json.loads((out / "report.json").read_text(encoding="utf-8"))
+ return [finding["check_id"] for finding in report["findings"]]
+
+
+def _action_text(payload: dict) -> str:
+ action = payload["control"]["next_action"]
+ return " ".join(str(action.get(key) or "") for key in ("kind", "command", "expects", "why"))
+
+
+# --- no detectable base ------------------------------------------------------
+
+
+def test_no_detectable_base_fails_closed_naming_base_and_no_base(tmp_path: Path) -> None:
+ origin, workflow_pr, _ = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", workflow_pr)
+
+ result, payload = _verify(
+ ci, "--ci-mode", "strict", "--head", workflow_pr, out=tmp_path / "out"
+ )
+
+ assert result.exit_code == 2, result.output
+ assert payload["execution"] == "failed"
+ assert payload["merge_verdict"] == "unknown"
+ assert payload["can_merge_without_human"] is False
+ assert payload["control"]["state"] != "complete"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert payload["base_ref"] is None
+ assert payload["base_status"] == "ref_missing"
+ status = payload["diff_status"]
+ assert status["completeness"] == "unavailable"
+ assert "base_unresolved" in status["detail"]
+ assert "--base" in status["remediation"] and "--no-base" in status["remediation"]
+ assert "base_unresolved" in payload["headline"]
+ action = _action_text(payload)
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ assert "--base" in action and "--no-base" in action
+ assert not (tmp_path / "out" / "report.json").exists()
+
+
+def test_the_same_change_with_its_base_named_is_blocked(tmp_path: Path) -> None:
+ """The contrast that makes the stop above necessary: the change is a gate removal."""
+
+ origin, workflow_pr, _ = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", workflow_pr)
+
+ result, payload = _verify(
+ ci,
+ "--ci-mode", "strict",
+ "--base", "origin/develop",
+ "--head", workflow_pr,
+ out=tmp_path / "out",
+ )
+
+ assert result.exit_code == 20, result.output
+ assert payload["merge_verdict"] == "blocked"
+ assert CHECK_ID in _finding_ids(tmp_path / "out")
+
+
+def test_no_base_flag_still_opts_out_explicitly(tmp_path: Path) -> None:
+ origin, workflow_pr, _ = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", workflow_pr)
+
+ result, payload = _verify(
+ ci, "--no-base", "--head", workflow_pr, out=tmp_path / "out"
+ )
+
+ assert result.exit_code == 0, result.output
+ assert payload["base_status"] == "not_requested"
+ assert payload["diff_status"]["completeness"] == "complete"
+
+
+def test_a_remote_less_repository_uses_its_local_main(tmp_path: Path) -> None:
+ repo = tmp_path / "repo"
+ _project(repo, branch="main")
+ _git(repo, "checkout", "-q", "-b", "feature")
+ (repo / GATE).unlink()
+ _commit(repo, "delete the gate workflow")
+
+ result, payload = _verify(repo, "--ci-mode", "strict", out=tmp_path / "out")
+
+ assert payload["base_ref"] == "main"
+ assert GATE in payload["changed_files"]
+ assert payload["control"]["state"] != "complete"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert result.exit_code == 20, result.output
+ assert payload["merge_verdict"] == "blocked"
+ assert CHECK_ID in _finding_ids(tmp_path / "out")
+
+
+def test_on_the_default_branch_tip_there_is_nothing_committed_to_compare(
+ tmp_path: Path,
+) -> None:
+ """A default branch at ``HEAD`` resolves the comparison; it is not missing."""
+
+ repo = tmp_path / "repo"
+ _project(repo, branch="main")
+
+ result, payload = _verify(repo, out=tmp_path / "out")
+
+ assert result.exit_code == 0, result.output
+ assert payload["diff_status"]["completeness"] == "complete"
+ assert payload["base_status"] == "not_requested"
+
+
+# --- a deleted manifest -------------------------------------------------------
+
+
+def _assert_manifest_removal_stop(result, payload: dict) -> None:
+ assert result.exit_code != 0, result.output
+ action = payload["control"]["next_action"]
+ assert action["actor"] == "human"
+ assert action["kind"] == "stop"
+ assert payload["control"]["state"] == "human_review_required"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert payload["can_merge_without_human"] is False
+ assert "removes" in payload["headline"] and "shipgate.yaml" in payload["headline"]
+ rendered = json.dumps(payload["control"])
+ for misroute in ("configure", "initialize", "init --write", "--preview"):
+ assert misroute not in rendered
+
+
+def test_deleting_the_manifest_with_a_resolvable_base_is_a_human_stop(
+ tmp_path: Path,
+) -> None:
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", manifest_pr)
+
+ for mode in ("advisory", "strict"):
+ out = tmp_path / f"out-{mode}"
+ result, payload = _verify(
+ ci,
+ "--ci-mode", mode,
+ "--base", "origin/develop",
+ "--head", manifest_pr,
+ out=out,
+ )
+ _assert_manifest_removal_stop(result, payload)
+ assert payload["base_ref"] == "origin/develop"
+
+
+def test_deleting_the_manifest_without_a_resolvable_base_fails_closed(
+ tmp_path: Path,
+) -> None:
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", manifest_pr)
+
+ result, payload = _verify(ci, "--head", manifest_pr, out=tmp_path / "out")
+
+ assert result.exit_code == 2, result.output
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ assert "base_unresolved" in payload["diff_status"]["detail"]
+ assert payload["control"]["permissions"]["merge"] is False
+ assert "configure" not in json.dumps(payload["control"])
+
+
+def test_deleting_the_manifest_with_a_named_base_that_is_not_here_fails_closed(
+ tmp_path: Path,
+) -> None:
+ """``diff_base: target`` without ``fetch-depth: 0`` names a base the
+ checkout does not hold. It cannot show whether the manifest was removed,
+ so it is an unknown base in both modes, never the setup or host route."""
+
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", manifest_pr)
+
+ for mode in ("advisory", "strict"):
+ result, payload = _verify(
+ ci,
+ "--ci-mode", mode,
+ "--base", "origin/not-fetched",
+ "--head", manifest_pr,
+ out=tmp_path / f"out-{mode}",
+ )
+
+ assert result.exit_code == 2, result.output
+ assert payload["base_status"] == "ref_missing"
+ assert payload["diff_status"]["reason"] == "refs_missing"
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert payload.get("host_comparison") is None
+ rendered = json.dumps(payload["control"])
+ for misroute in ("configure", "initialize", "init --write"):
+ assert misroute not in rendered
+
+
+def _depth_one(origin: Path, destination: Path, sha: str) -> Path:
+ """The default ``actions/checkout`` layout: one commit, fetched by SHA."""
+
+ _git(origin, "config", "uploadpack.allowAnySHA1InWant", "true")
+ destination.mkdir()
+ _git(destination, "init", "-q", "-b", "ci")
+ _git(destination, "remote", "add", "origin", f"file://{origin}")
+ _git(destination, "fetch", "-q", "--depth=1", "origin", sha)
+ _git(destination, "checkout", "-q", "--detach", sha)
+ assert _git(destination, "rev-parse", "--is-shallow-repository") == "true"
+ return destination
+
+
+def _assert_unknown_base_stop(result, payload: dict) -> None:
+ assert result.exit_code == 2, result.output
+ assert payload["base_status"] == "ref_missing"
+ assert payload["diff_status"]["reason"] == "refs_missing"
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert payload.get("host_comparison") is None
+ rendered = json.dumps(payload["control"])
+ for misroute in ("configure", "initialize", "init --write"):
+ assert misroute not in rendered
+
+
+def test_a_depth_one_checkout_cannot_show_the_manifest_never_existed(
+ tmp_path: Path,
+) -> None:
+ """``diff_base: target`` on the default depth-1 checkout (#1023 review
+ cycle 2). History there ends at the PR commit, so ``rev-list`` finds no
+ commit that ever held the manifest; that silence is not proof."""
+
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ shallow = _depth_one(origin, tmp_path / "shallow", manifest_pr)
+
+ for mode in ("advisory", "strict"):
+ result, payload = _verify(
+ shallow,
+ "--ci-mode", mode,
+ "--base", "origin/develop",
+ "--head", manifest_pr,
+ out=tmp_path / f"out-{mode}",
+ )
+ _assert_unknown_base_stop(result, payload)
+
+
+def test_a_manifest_free_depth_one_checkout_with_an_absent_base_stops(
+ tmp_path: Path,
+) -> None:
+ """The same layout without any manifest: advisory used to exit 0 on the
+ host route, and strict used to route to ``configure``. Neither can be told
+ from a deleted gate here, so both stop for the base."""
+
+ origin = tmp_path / "origin"
+ origin.mkdir()
+ _git(origin, "init", "-q", "-b", "main")
+ _write(origin / ".claude" / "settings.json", '{"permissions": {"deny": ["Bash(rm *)"]}}\n')
+ _commit(origin, "base")
+ _git(origin, "checkout", "-q", "-b", "change")
+ _write(origin / ".claude" / "settings.json", '{"permissions": {"allow": ["Bash(*)"]}}\n')
+ change = _commit(origin, "widen")
+ shallow = _depth_one(origin, tmp_path / "shallow", change)
+
+ for mode in ("advisory", "strict"):
+ result, payload = _verify(
+ shallow,
+ "--ci-mode", mode,
+ "--base", "origin/main",
+ "--head", change,
+ out=tmp_path / f"out-{mode}",
+ )
+ _assert_unknown_base_stop(result, payload)
+
+
+def test_a_graft_file_cannot_show_the_manifest_never_existed(tmp_path: Path) -> None:
+ """``info/grafts`` cuts history without making the clone shallow."""
+
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ ci = _ci_clone(origin, tmp_path / "ci", manifest_pr)
+ _write(ci / ".git" / "info" / "grafts", f"{manifest_pr}\n")
+ assert _git(ci, "rev-parse", "--is-shallow-repository") == "false"
+
+ for mode in ("advisory", "strict"):
+ result, payload = _verify(
+ ci,
+ "--ci-mode", mode,
+ "--base", "origin/not-fetched",
+ "--head", manifest_pr,
+ out=tmp_path / f"out-{mode}",
+ )
+ _assert_unknown_base_stop(result, payload)
+
+
+def test_a_named_base_that_is_not_here_keeps_setup_where_history_shows_no_manifest(
+ tmp_path: Path,
+) -> None:
+ """Complete history that never held the manifest is first adoption."""
+
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init", "-q", "-b", "main")
+ _write(repo / "README.md", "hello\n")
+ _commit(repo, "initial")
+
+ result, payload = _verify(
+ repo, "--ci-mode", "strict", "--base", "origin/main", out=tmp_path / "out"
+ )
+
+ assert result.exit_code == 2, result.output
+ assert payload["control"]["next_action"]["kind"] == "configure"
+
+
+def test_a_shallow_base_that_holds_the_manifest_asks_for_history(tmp_path: Path) -> None:
+ """The base holds the manifest, and only the merge base says whether this
+ change removed it; a depth-1 checkout cannot read that, so it says so."""
+
+ origin, _, manifest_pr = _develop_origin(tmp_path)
+ _git(origin, "config", "uploadpack.allowAnySHA1InWant", "true")
+ shallow = tmp_path / "shallow"
+ shallow.mkdir()
+ _git(shallow, "init", "-q", "-b", "ci")
+ _git(shallow, "remote", "add", "origin", f"file://{origin}")
+ _git(shallow, "fetch", "-q", "--depth=1", "origin", manifest_pr)
+ _git(shallow, "fetch", "-q", "--depth=1", "origin", "+develop:refs/remotes/origin/develop")
+ _git(shallow, "checkout", "-q", "--detach", manifest_pr)
+
+ result, payload = _verify(
+ shallow, "--base", "origin/develop", "--head", manifest_pr, out=tmp_path / "out"
+ )
+
+ assert result.exit_code == 2, result.output
+ assert payload["diff_status"]["reason"] == "merge_base_missing"
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ assert payload["control"]["permissions"]["merge"] is False
+ assert "configure" not in json.dumps(payload["control"])
+
+
+def test_an_uncommitted_manifest_deletion_is_a_human_stop(tmp_path: Path) -> None:
+ repo = tmp_path / "repo"
+ _project(repo, branch="main")
+ (repo / "shipgate.yaml").unlink()
+
+ result, payload = _verify(repo, out=tmp_path / "out")
+
+ _assert_manifest_removal_stop(result, payload)
+
+ envelope = runner.invoke(
+ app,
+ [
+ "verify",
+ "--workspace", str(repo),
+ "--out", str(tmp_path / "out-control"),
+ "--format", "control",
+ ],
+ )
+ control = json.loads(envelope.stdout)
+ assert envelope.exit_code == 2, envelope.output
+ assert control["control_state"] == "human_review_required"
+ assert control["next_action"]["kind"] == "stop"
+ assert control["permissions"]["merge"] is False
+
+
+def test_a_repository_that_never_had_the_manifest_keeps_the_setup_route(
+ tmp_path: Path,
+) -> None:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init", "-q", "-b", "main")
+ _write(repo / "README.md", "hello\n")
+ _commit(repo, "initial")
+
+ result, payload = _verify(repo, "--ci-mode", "strict", out=tmp_path / "out")
+
+ assert result.exit_code == 2, result.output
+ assert payload["control"]["next_action"]["kind"] == "configure"
+
+
+# --- the gate check anchors at the repository root ---------------------------
+
+
+def _monorepo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ _project(repo, branch="main", manifest_dir="apps/agent")
+ _git(repo, "checkout", "-q", "-b", "feature")
+ return repo
+
+
+def test_a_nested_manifest_does_not_read_a_root_workflow_edit_as_removal(
+ tmp_path: Path,
+) -> None:
+ repo = _monorepo(tmp_path)
+ with (repo / GATE).open("a", encoding="utf-8") as handle:
+ handle.write("# a comment\n")
+ _commit(repo, "comment the workflow")
+
+ out = tmp_path / "out"
+ result, payload = _verify(
+ repo, "--base", "main", out=out, config="apps/agent/shipgate.yaml"
+ )
+
+ assert GATE in payload["changed_files"]
+ assert payload["execution"] == "succeeded", result.output
+ assert CHECK_ID not in _finding_ids(out)
+
+
+def test_a_nested_manifest_still_sees_the_root_workflow_deleted(tmp_path: Path) -> None:
+ repo = _monorepo(tmp_path)
+ (repo / GATE).unlink()
+ _commit(repo, "delete the gate workflow")
+
+ out = tmp_path / "out"
+ result, payload = _verify(
+ repo,
+ "--ci-mode", "strict",
+ "--base", "main",
+ out=out,
+ config="apps/agent/shipgate.yaml",
+ )
+
+ assert result.exit_code == 20, result.output
+ assert CHECK_ID in _finding_ids(out)
+
+
+def test_a_nested_copy_of_the_workflow_does_not_hide_a_root_deletion(
+ tmp_path: Path,
+) -> None:
+ repo = _monorepo(tmp_path)
+ _write(repo / "apps" / "agent" / GATE, WORKFLOW)
+ _commit(repo, "add a nested copy")
+ _git(repo, "branch", "-f", "main", "HEAD")
+ (repo / GATE).unlink()
+ _commit(repo, "delete the root gate workflow")
+
+ out = tmp_path / "out"
+ result, _payload = _verify(
+ repo,
+ "--ci-mode", "strict",
+ "--base", "main",
+ out=out,
+ config="apps/agent/shipgate.yaml",
+ )
+
+ assert result.exit_code == 20, result.output
+ assert CHECK_ID in _finding_ids(out)
+
+
+# --- a depth-1 checkout ------------------------------------------------------
+
+
+def test_a_depth_one_checkout_archives_its_head(tmp_path: Path) -> None:
+ origin, workflow_pr, _ = _develop_origin(tmp_path)
+ _git(origin, "config", "uploadpack.allowAnySHA1InWant", "true")
+ shallow = tmp_path / "shallow"
+ shallow.mkdir()
+ _git(shallow, "init", "-q", "-b", "ci")
+ _git(shallow, "remote", "add", "origin", f"file://{origin}")
+ _git(shallow, "fetch", "-q", "--depth=1", "origin", workflow_pr)
+ _git(shallow, "checkout", "-q", "--detach", workflow_pr)
+ assert _git(shallow, "rev-parse", "--is-shallow-repository") == "true"
+
+ result, payload = _verify(
+ shallow, "--no-base", "--head", workflow_pr, out=tmp_path / "out"
+ )
+
+ assert result.exit_code == 0, result.output
+ assert payload["execution"] == "succeeded"
+
+ result, payload = _verify(shallow, "--head", workflow_pr, out=tmp_path / "out2")
+
+ assert result.exit_code == 2, result.output
+ assert payload["control"]["next_action"]["kind"] == "fetch_base"
+ remediation = payload["diff_status"]["remediation"]
+ assert "fetch-depth: 0" in remediation
+ assert "shipgate.yaml" not in _action_text(payload)
+
+
+# --- check leaves verify's reports out of its change set ---------------------
+
+
+def test_check_after_verify_does_not_count_the_reports(tmp_path: Path) -> None:
+ repo = tmp_path / "repo"
+ _project(repo, branch="main")
+
+ result, _payload = _verify(repo, "--no-base", out=repo / "agents-shipgate-reports")
+ assert result.exit_code == 0, result.output
+ assert (repo / "agents-shipgate-reports" / "verifier.json").is_file()
+ _write(repo / "notes.md", "an edit the developer made\n")
+
+ checked = runner.invoke(
+ app,
+ [
+ "check",
+ "--agent", "claude-code",
+ "--workspace", str(repo),
+ "--config", "shipgate.yaml",
+ "--base", "HEAD",
+ "--format", "agent-boundary-json",
+ ],
+ )
+
+ payload = json.loads(checked.stdout)
+ assert "notes.md" in payload["changed_files"]
+ assert not [
+ path for path in payload["changed_files"] if path.startswith("agents-shipgate-reports/")
+ ]
]