fix(framework): enforce pinned forge merge policy
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
jason.woltje committed 2026-11-05 11:47:16 -06:00
1 parent de99f7263f
commit abab3a67e2
19 files changed
+427 -186

No files matched your search

@@ -124,7 +124,9 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
The guard exits nonzero for non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT`; merge holds until evidence is available. The legacy `pr-merge.sh --no-ci-expected` path forwards that assertion to the queue guard, which requires repository admin and `MOSAIC_GIT_IDENTITY`. The legacy merge interface retains its options and consults the declaration at the PR's base commit. Pass `--expect-head <approved-full-sha>` to bind a verdict; the `--json` adapter requires it. Gitea uses an atomic head pin and GitHub passes the expected SHA.
REST forge wrappers require `MOSAIC_GIT_IDENTITY` and an operator-provisioned token in the identity's host-specific store. For hosts other than `git.uscllc.com` and `git.mosaicstack.dev`, that store is `$MOSAIC_BRAIN_HOME/credentials/forge/<host>/<id>.token` (brain defaults to `~/.mosaic`). The two fleet hosts use their per-seat or per-service token paths; see [API wrapper identity and credentials](../tools/git/README.md#api-wrapper-identity-and-credentials). API token resolution no longer falls back to git-config identity, shared `credentials.sh` tokens, `GITEA_TOKEN`, or `~/.git-credentials`. The separate tea login resolver retains its legacy git-config and host-default behavior; set the environment identity to align both paths.
### Code Review (Codex)
+29 -125
View File
@@ -40,109 +40,39 @@ Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` f
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
## Per-agent Gitea identity (Gate-16 author≠reviewer)
## API wrapper identity and credentials
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
The REST wrappers require `MOSAIC_GIT_IDENTITY` to name the acting account. They
select exactly one host-bound token file, fail closed when it is missing, and
verify `/user` against that identity before a write. Operators provision these
files; the wrappers do not create them. `<brain>` is `MOSAIC_BRAIN_HOME` when
set, otherwise `~/.mosaic`.
`git-credential-mosaic`, `get_gitea_token()`, and the `gitea-mosaicstack` /
`gitea-usc` arms of `load_credentials` resolve an optional **per-agent identity**:
| Host and identity | Token file |
| --- | --- |
| `git.mosaicstack.dev` or `git.uscllc.com`, with `<brain>/fleet/agents/<id>/` present | `<brain>/fleet/agents/<id>/secrets/gitea-{mosaicstack,usc}-<id>.token` |
| Those hosts, other identities | `~/.config/mosaic/secrets/gitea-tokens/gitea-{mosaicstack,usc}-<id>.token` |
| Any other host, including github.com | `<brain>/credentials/forge/<host>/<id>.token` |
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
3. (git-credential-mosaic only) the username git itself supplies for the credential
request.
Use the actual hostname (and port when specified) as `<host>` and the matching
instance name in the two fleet-host paths. Keep tokens private and out of git.
There is no fallback to another identity or store.
### Which store a credential is read from
**Migration:** set `MOSAIC_GIT_IDENTITY` explicitly and provision the appropriate
file before upgrading. API token resolution no longer falls back to git config
`mosaic.gitIdentity`, a shared `credentials.sh` token, `GITEA_TOKEN`, or
`~/.git-credentials`. The git credential helper and other service credential
loaders have their own contracts and are not configured by this change.
The store is chosen by what the identity **is**, not by which file happens to exist first:
The separate tea-based read paths resolve a tea login rather than a REST token;
their legacy resolver still accepts `git config mosaic.gitIdentity` or a host
default when the environment identity is absent. Set `MOSAIC_GIT_IDENTITY` for
both paths to avoid selecting different actors. Explicit `--login` overrides
remain host-bound as described above.
| The identity | Its credential is read from |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
### Enabling the Git credential helper for a clone
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
`packages/mosaic/src/fleet/brain-home.ts` performs.
The Gitea arms of `load_credentials` obtain a seat token through the production
`git-credential-mosaic` entrypoint, rather than reading the slot directly. That retains
the entrypoint's clean-environment and process-ancestry fence. The Gitea URL remains
provider configuration from the service store. A caller-supplied `GITEA_TOKEN` retains
its established environment precedence.
**There is no precedence between the two stores and no fallback from one to the other.**
A seat whose slot is empty is refused even when a same-named token sits in the framework
store. One credential lives in exactly one location: a second copy is drift rather than
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
revoked token and sends whoever debugs it to the wrong place.
### What happens when nothing resolves
| identity resolves | token in its store | host runs a fleet | result |
| ----------------- | ------------------ | ----------------- | ------------------------- |
| yes | yes | — | that identity + token |
| yes | no | — | **fail closed** |
| no | — | yes | **fail closed** |
| no | — | no | shared account, unchanged |
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
uses to decide a brain is active.
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
names the identity, its source, the store it resolved to and the path that was expected,
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
The git operation fails; nothing is attributed to anyone.
The shared-account fallback that used to cover these two cases is why a PR could be
authored, commented and merged under an account whose owner did not open it — every seat
shared one identity, so the record could not be traced back afterwards. An
under-provisioned agent is refused rather than handed the most privileged account
available.
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
account still answers, so this is a no-op for an operator who has not provisioned per-slot
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
environment variable that restores the fallback; one would reintroduce exactly the
substitution this removes.
### The tea path: login resolution (#1356)
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
therefore resolve a login name, not a token, and the resolution follows the same identity
as above:
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev` → `mosaicstack`,
`git.uscllc.com` → `usc`), or from the owner when `--repo owner/name` is given.
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
identity. When `tea` itself is not installed the message says so instead, since "no such
login" would send the reader to create a login they cannot create.
5. With **no identity set**, the old host-default behaviour is unchanged (first login
configured for that host, else the API fallback).
Step 4 replaced a fallback that picked any login configured for the host, which meant a
seat with no login of its own silently acted as whichever seat had configured one. That
satisfied the author≠reviewer gate on paper while one actor held both names.
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
its secrets store into tea's config under the canonical name. tea's config is a derived
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
alias holding a seat's token blocks its canonical name (tea refuses one token under two
names); `--adopt` renames it.
### Enabling it for a clone
This section covers Git authentication, not the REST wrapper identity above.
The framework installer syncs `git-credential-mosaic` to
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
@@ -167,32 +97,6 @@ otherwise careful never to touch. Because identity is already resolved per-workt
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
too, so a documented manual step is the right shape here, not a global auto-write.
### Running these tests
`MOSAIC_GIT_IDENTITY` is inherited into each test's sandbox `HOME`, and **the tests disagree
about which value they need**, so no single ambient value passes all 29. Measured on `next` at
`a480ee83`, two full passes differing only in that variable:
| tests | identity exported | identity unset |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------- |
| `gitea-login-resolution`, `issue-comment-readback`, `issue-create-interactive-auth`, `pr-edit`, `pr-merge-gitea-empty-uid`, `pr-metadata-gitea` | **fail** | pass |
| `issue-close-fail-closed` | pass | **fail** |
| remaining 22 | pass | pass |
The six fail because inside a sandbox `HOME` the identity has no `fleet/agents/<id>/`
directory, so it is classified as a **service identity**, its store is unpopulated, and the
resolver correctly refuses with `Refusing to borrow another slot's token`. That is the
documented fail-closed behaviour above, reached from a state the test never intended.
`issue-close-fail-closed` is the mirror image: it asserts that no comment POST is attempted, so
it needs an identity resolving to an empty slot, and with the variable unset the shared account
answers and the POST goes through.
These read as wrapper regressions rather than as environment. Two seats independently
misdiagnosed them as a patch defect while reviewing #1352. Until each test controls its own
value (#1353), `env -u MOSAIC_GIT_IDENTITY` is the closest thing to a clean run at 28/29, with
`issue-close-fail-closed` the expected failure — and **"the suite passes" is not a statement
anyone can make here without naming the ambient value that produced it.**
### PowerShell parity
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
@@ -315,7 +219,7 @@ scheduling may merge immediately and returns observed merged state rather than q
Schema 1/2 remain readable. Schema 3 adds optional `merge`, with strict key/value validation
through the shared declaration validator and repo-decl helper. Consumers apply the existing
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash.
branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash], default=squash. The legacy merge interface retains its options and reads a declaration from the PR base commit; a missing or unreadable declaration uses the legacy path. It refuses when that policy selects a non-squash default, unless an explicit squash is allowed. Explicit `--json` calls use the adapter and require `--expect-head`.
```json
{
@@ -334,8 +238,8 @@ branch gates to both versions 2 and 3. Absent merge policy means allowed=[squash
```
`allowed_methods` is non-empty and unique; default and shape methods must belong to it.
Unknown keys/shape names fail validation. Promotion is declared trunk→release; absorb is
release→trunk and requires its explicit shape mapping; other allowed contributions are task.
Unknown keys/shape names fail validation. Promotion is declared trunk→release within the same repository; absorb is
release→trunk within the same repository and requires its explicit shape mapping; other allowed contributions are task.
Other targets still fail transition validation. Method requests cannot override a shape mapping.
Invalid declarations never fall back to a wider merge policy.
@@ -126,15 +126,20 @@ def checks(statuses, runs, required, allow_none=False):
return dict(state=state, required=required, observed=observed, complete=True)
def reviews(items, head, author, required_count=1):
def reviews(items, head, author, required_count=1, provider='github', official_required=False):
latest = {}
for r in sorted(items, key=lambda r: r.get('id', 0)):
login = (r.get('user') or {}).get('login')
state = str(r.get('state', '')).upper()
if login and state not in ('COMMENTED', 'PENDING'):
if provider != 'github':
if r.get('dismissed') is True or state not in ('APPROVED', 'REQUEST_CHANGES'):
continue
elif state in ('COMMENTED', 'PENDING'):
continue
if login:
latest[login] = r
rejected = any(str(r.get('state')).upper() in ('REQUEST_CHANGES', 'CHANGES_REQUESTED') for r in latest.values())
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head for login, r in latest.items()) >= max(1, required_count) and not rejected
approved = sum(login != author and str(r.get('state')).upper() == 'APPROVED' and r.get('commit_id') == head and (not official_required or r.get('official') is True) for login, r in latest.items()) >= max(1, required_count) and not rejected
return dict(decision='changes_requested' if rejected else 'approved' if approved else 'missing', reviewed_sha=head if approved else None, complete=True, approved=approved)
@@ -276,7 +281,7 @@ class Client:
raise Failure(75, 'transient', 'Provider server error', outcome='unknown' if writing else 'none')
if not 200 <= status < 300:
raise Failure(3 if writing else 6, 'gate' if writing else 'malformed', 'Provider refused request (HTTP %s)' % status)
if not raw.strip() and status == 204:
if not raw.strip() and (status == 204 or writing):
return None
try:
result = json.loads(raw)
@@ -309,7 +314,7 @@ class Client:
if not isinstance(value, list):
raise Failure(6, 'malformed', 'Provider collection is not a list')
result.extend(value)
if len(value) < LIMIT:
if not value or self.provider == 'github' and len(value) < LIMIT:
if len(result) < expected_total:
raise Failure(6,'incomplete','Provider returned fewer records than total_count')
return result
@@ -465,6 +470,7 @@ def protection(client, base):
return None, False
r = matching[0]
client.review_count = r.get('required_approvals',1)
client.review_official_required = r.get('enable_approvals_whitelist') is True
return (r.get('status_check_contexts') if r.get('enable_status_check') is True else None), False
@@ -482,7 +488,7 @@ def snapshot(client, number, expected):
check = checks(statuses, runs, required)
comments, threads, integration = review_comments(client, number)
issue_comments = client.pages(root + '/issues/' + str(number) + '/comments')
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1))
review = reviews(comments['reviews'], head, (p.get('user') or {}).get('login'), getattr(client,'review_count',1), client.provider, getattr(client,'review_official_required',False))
if getattr(client,'codeowners_required',False) and (integration or {}).get('review_decision') != 'APPROVED':
review.update(approved=False,decision='codeowner_approval_unknown',reviewed_sha=None)
unresolved = sum(t['isResolved'] is False for t in threads) if client.provider == 'github' else sum(c['resolved'] is False for c in comments['comments']) if all(c['resolved'] is not None for c in comments['comments']) else None
@@ -651,7 +657,7 @@ def declared_method(client, p, requested):
origin_normal = run([str(HERE.parent/'structure/validate-repo-json.sh'), '--normalize-remote', origin])
if normal.returncode or origin_normal.returncode or normal.stdout != origin_normal.stdout:
raise Failure(3, 'policy', 'POLICY-REFUSED: target declaration canonical remote differs from origin')
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref']]
args = ['python3', '-I', str(HERE/'merge-policy.py'), str(declaration), '--head', p['head']['ref'], '--base', base['ref'], '--head-repo', (p['head'].get('repo') or {}).get('full_name') or '', '--base-repo', client.repo]
if requested:
args += ['--method', requested]
result = run(args)
@@ -692,8 +698,12 @@ def merge(client, number, requested, expected):
key = 'do' if client.version == '28.2.0' else 'Do'
client.http(client.root+'/pulls/'+str(number)+'/merge','POST',{key:method,'head_commit_id':expected})
try:
after = same_base(same_head(client,number,expected), p)
if after.get('merged') is not True or not after.get('merge_commit_sha'):
after = same_head(client,number,expected)
original_identity, after_identity = base_identity(p), base_identity(after)
if original_identity[:1] + original_identity[2:] != after_identity[:1] + after_identity[2:]:
raise Failure(75, 'base_moved', 'PR identity changed after merge', outcome='unknown')
merge_sha = after.get('merge_commit_sha')
if after.get('merged') is not True or not isinstance(merge_sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', merge_sha):
raise Failure(6,'verification','Merge not proven by PR readback',outcome='unknown')
except Failure as e:
e.outcome='unknown'
@@ -28,12 +28,16 @@ def policy(doc):
return dict(p, shapes=shapes)
def resolve(doc, head, base, request):
def resolve(doc, head, base, request, head_repo=None, base_repo=None):
p = policy(doc)
if not head or not base or head == base:
return 3, {'error':'POLICY-REFUSED: head/base must identify a non-self transition'}
trunk, release = doc.get('integration_trunk'), doc.get('release_branch')
shape = 'promotion' if head == trunk and base == release and trunk != release else 'absorb' if head == release and base == trunk and trunk != release else 'task'
special_pair = trunk != release and ((head == trunk and base == release) or (head == release and base == trunk))
if special_pair and not (head_repo and base_repo):
return 3, {'error':'POLICY-REFUSED: promotion or absorb requires both repository identities'}
same_repo = bool(head_repo and base_repo and head_repo == base_repo)
shape = 'promotion' if same_repo and head == trunk and base == release and trunk != release else 'absorb' if same_repo and head == release and base == trunk and trunk != release else 'task'
mapped = p['shapes'].get(shape)
selected = request or mapped or p['default_method']
if selected not in p['allowed_methods'] or mapped and selected != mapped:
@@ -52,13 +56,15 @@ def main():
parser.add_argument('--head')
parser.add_argument('--base')
parser.add_argument('--method')
parser.add_argument('--head-repo')
parser.add_argument('--base-repo')
a = parser.parse_args()
try:
doc = json.loads(pathlib.Path(a.file).read_text())
if not isinstance(doc,dict):
raise ValueError('declaration must be an object')
p = policy(doc)
code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method)
code, value = (0,p) if a.validate else resolve(doc,a.head,a.base,a.method,a.head_repo,a.base_repo)
except (OSError, ValueError, TypeError) as e:
code, value = 6, {'error':'VALIDATION_ERROR merge: ' + str(e)}
print(json.dumps(value, sort_keys=True))
+59 -29
View File
@@ -20,19 +20,23 @@ for ((arg_i=1; arg_i<=$#; arg_i++)); do
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
fi
done
# A declared merge policy also applies to callers that have not adopted --json.
declaration_root=$(git rev-parse --show-toplevel 2>/dev/null) || declaration_root=""
if [[ -n "$declaration_root" && -f "$declaration_root/.mosaic/repo.json" ]] && python3 -I -c 'import json,sys
try:
d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1)
except (ValueError,OSError):
sys.exit(0)' "$declaration_root/.mosaic/repo.json"; then
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "$@"
fi
# Preserve the original argument vector if pinned policy selects the adapter.
ORIGINAL_ARGS=("$@")
MERGE_TEMP_DIRS=()
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Default values
PR_NUMBER=""
MERGE_METHOD="squash"
METHOD_EXPLICIT=false
DELETE_BRANCH=false
DRY_RUN=false
EXPECT_HEAD=""
@@ -83,6 +87,7 @@ while [[ $# -gt 0 ]]; do
;;
-m|--method)
MERGE_METHOD="$2"
METHOD_EXPLICIT=true
shift 2
;;
-d|--delete-branch)
@@ -142,10 +147,6 @@ if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
exit 1
fi
if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "POLICY-REFUSED: absent merge policy permits squash only." >&2
exit 3
fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
@@ -165,20 +166,33 @@ fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
BASE_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefOid") or "").strip())')"
if [[ ! "$BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "POLICY-REFUSED: PR base commit SHA is missing or invalid." >&2
exit 3
fi
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("headRepository") or ""; owner=d.get("headRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')"
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; d=json.load(sys.stdin); value=d.get("baseRepository") or ""; owner=d.get("baseRepositoryOwner") or {}; name=value.get("name") or "" if isinstance(value,dict) else ""; login=owner.get("login") or "" if isinstance(owner,dict) else ""; print((value.get("nameWithOwner") or value.get("full_name") or (login+"/"+name if login and name else "")) if isinstance(value,dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
# Target branches are validated against the declaration, NEVER hardcoded;
# the legacy main/next check survives only for undeclared repos during the
# rollout window (4.3 irreversible class, loud warning).
# A missing or unreadable declaration at the observed base keeps legacy behavior.
# Never load merge policy or transition data from the caller's working tree.
pinned_decl=""
pinned_dir=$(mktemp -d "${TMPDIR:-/tmp}/mosaic-merge-policy.XXXXXX")
MERGE_TEMP_DIRS+=("$pinned_dir")
if git show "$BASE_SHA:.mosaic/repo.json" > "$pinned_dir/repo.json" 2>/dev/null; then
pinned_decl="$pinned_dir/repo.json"
fi
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
source "$SCRIPT_DIR/repo-decl.sh"
repo_decl_load
repo_decl_load "$pinned_decl"
else
DECL_STATE=absent; DECL_SCHEMA=""
# Compatibility callbacks are invoked by declaration checks below.
@@ -191,6 +205,35 @@ else
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
# A base declaration with a merge key governs both paths. Validate it before
# allowing legacy squash; non-squash requests go through the normalized adapter.
has_merge_policy=false
if [[ -n "${pinned_decl:-}" ]] && python3 -I -c 'import json,sys
try:
d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d,dict) and "merge" in d else 1)
except (ValueError,OSError): sys.exit(1)' "$pinned_decl"; then
has_merge_policy=true
fi
if [[ "$has_merge_policy" == true ]]; then
if [[ "$DECL_STATE" != valid ]]; then
echo "POLICY-REFUSED: invalid pinned base merge declaration." >&2
exit 3
fi
if [[ "$MERGE_METHOD" != squash ]]; then
cleanup_merge_temp_dirs
exec python3 -I "$SCRIPT_DIR/forge-api.py" merge "${ORIGINAL_ARGS[@]}"
fi
policy_args=(--head-repo "$HEAD_REPO" --base-repo "$BASE_REPO")
[[ "$METHOD_EXPLICIT" == true ]] && policy_args+=(--method squash)
if ! policy_result=$(repo_decl_merge_method "$HEAD_BRANCH" "$BASE_BRANCH" "${policy_args[@]}") ||
! python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("method") == "squash" else 1)' "$policy_result"; then
echo "POLICY-REFUSED: pinned base merge policy does not select legacy squash." >&2
exit 3
fi
elif [[ "$MERGE_METHOD" != squash ]]; then
echo "POLICY-REFUSED: absent merge policy permits squash only." >&2
exit 3
fi
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
fi
@@ -279,7 +322,6 @@ else:
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
@@ -323,18 +365,6 @@ print(ascii(message))
PY
}
# EXIT invokes this cleanup callback.
# shellcheck disable=SC2317
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch_gitea_pr_head() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local response_file raw_code api_url auth_config curl_rc
@@ -111,7 +111,28 @@ PY
detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,headRepositoryOwner,baseRefName,baseRefOid,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
# gh pr view does not expose the base repository. Bind both repositories,
# refs and SHAs to the same pull-request REST observation.
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
HOST=$(get_remote_host)
RAW=$(gh api --hostname "$HOST" "/repos/$OWNER/$REPO/pulls/$PR_NUMBER")
METADATA=$(PR_VIEW_JSON="$METADATA" PR_REST_JSON="$RAW" python3 -I - "$PR_NUMBER" <<'PY_GITHUB'
import json, os, sys
view = json.loads(os.environ['PR_VIEW_JSON'])
pr = json.loads(os.environ['PR_REST_JSON'])
if not isinstance(view, dict) or not isinstance(pr, dict) or pr.get('number') != int(sys.argv[1]):
raise SystemExit('Error: GitHub PR metadata identity mismatch')
for side in ('head', 'base'):
item = pr.get(side) or {}
repo = item.get('repo') or {}
view[side + 'RefName'] = item.get('ref') or ''
view[side + 'RefOid'] = item.get('sha') or ''
view[side + 'Repository'] = repo.get('full_name') or ''
print(json.dumps(view))
PY_GITHUB
)
write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner)
@@ -235,6 +256,7 @@ normalized = {
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'baseRefOid': nested(data, 'base', 'sha') or '',
'baseRepository': base_repo,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
@@ -48,11 +48,15 @@ repo_decl_load() {
DECL_ORIGIN_N=""
local root
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
repo_decl_warn "no git repository — declaration consumption skipped"
return 0
}
DECL_FILE="$root/.mosaic/repo.json"
if [ "$#" -gt 0 ]; then
DECL_FILE="$1"
else
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
repo_decl_warn "no git repository — declaration consumption skipped"
return 0
}
DECL_FILE="$root/.mosaic/repo.json"
fi
[ -f "$DECL_FILE" ] || return 0
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
@@ -179,7 +183,9 @@ repo_decl_check_transition() { # $1 head, $2 base
trunk-release)
if [ "$head" = "$DECL_RELEASE" ] && [ "$base" = "$DECL_TRUNK" ]; then
# Absorb is opt-in through a declared shape mapping, never a CLI exception.
if python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --head "$head" --base "$base" >/dev/null; then
local merge_policy
if merge_policy=$(python3 -I "$REPO_DECL_SCRIPT_DIR/merge-policy.py" "$DECL_FILE" --validate) &&
python3 -I -c 'import json,sys; sys.exit(0 if json.loads(sys.argv[1]).get("shapes", {}).get("absorb") else 1)' "$merge_policy"; then
return 0
fi
repo_decl_warn "absorb transition requires explicit merge policy"
@@ -74,6 +74,8 @@ class CLI(unittest.TestCase):
if '?' not in path and path!='graphql' and not path.startswith('/swagger.v1.json'):
pagination='per_page' if github else 'limit'
expected[method+' '+url+'?'+pagination+'=100&page=1']=entry
if not github and isinstance(response.get('data'),list):
expected[method+' '+url+'?limit=100&page=2']={'path':path,'response':{'data':[]}}
(self.root/'responses.json').write_text(json.dumps(expected))
def run_wrapper(self,name,*args,host='github.com'):
@@ -271,6 +273,11 @@ class Redirects(unittest.TestCase):
if self.headers.get('Content-Length'): self.rfile.read(int(self.headers['Content-Length']))
route=(self.command,urlsplit(self.path).path)
status,headers,data=(200,[],{'ok':True}) if trap else owner.routes.get(route,(418,[],{'unexpected':True}))
query=urlsplit(self.path).query
if route[1] in ('/relative-statuses','/moved-statuses') or route[1].endswith('/statuses'):
if query and status in (301,302,303,307,308):
headers=[(k,v+'?'+query if k.lower()=='location' and '?' not in v else v) for k,v in headers]
if query and 'page=2' in query and isinstance(data,list): data=[]
if isinstance(data,bytes):
self.connection.sendall(data);self.close_connection=True;return
self.send_response(status)
@@ -448,7 +455,7 @@ class Redirects(unittest.TestCase):
p=subprocess.run(['bash','-x',str(HERE/'pr-ci-wait.sh'),'--host',self.host,'--repo','owner/repo','--json','-n','1','-t','1','-i','1','--expect-head',self.head],cwd=self.root,env=self.env,capture_output=True,text=True,timeout=20)
self.assertEqual(p.returncode,0,p.stderr);data=json.loads(p.stdout)
self.assertEqual(data['head_sha'],self.head);self.assertEqual(data['data']['head_repository'],'fork/repo');self.assertEqual(data['data']['checks']['state'],'success')
self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path']=='/moved-statuses' for c in self.calls))
self.assertTrue(any(c['path'].startswith(key[1]) for c in self.calls));self.assertTrue(any(c['path'].split('?',1)[0]=='/moved-statuses' for c in self.calls))
self.assertEqual(self.trap_calls,[])
self.assertTrue(all(c['authenticated'] for c in self.calls if not c['path'].startswith('/swagger.v1.json')))
self.assertNotIn('fixture-token',p.stdout+p.stderr)
@@ -63,6 +63,103 @@ class Contract(unittest.TestCase):
self.assertFalse(self.api.reviews(reviews, 'new', 'author')['approved'])
def test_gitea_non_decisive_and_dismissed_reviews(self):
from unittest.mock import patch
fixture = SnapshotContract('test_ready_needs_all_evidence')
fixture.api = self.api
c = fixture.fake()
c.provider, c.version = 'gitea', '28.2.0'
def check(rows, official_required=False):
c.review_official_required = official_required
data = {'complete':True, 'reviews':rows, 'comments':[]}
with patch.object(self.api,'protection',return_value=(['build'],False)), patch.object(self.api,'review_comments',return_value=(data,[],None)):
return self.api.snapshot(c,1,None)['reviews']
for event in ('COMMENT', 'REQUEST_REVIEW', 'PENDING'):
with self.subTest(event=event):
rows = [dict(id=1, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a'*40),
dict(id=2, state=event, user={'login':'bob'}, commit_id='a'*40),
dict(id=3, state='APPROVED', user={'login':'alice'}, commit_id='a'*40)]
self.assertEqual(check(rows)['decision'], 'changes_requested')
rows = [dict(id=1, state='APPROVED', user={'login':'alice'}, commit_id='a'*40, dismissed=True)]
self.assertFalse(check(rows)['approved'])
rows[0]['dismissed'] = False
rows[0]['official'] = False
self.assertFalse(check(rows,True)['approved'])
rows[0]['official'] = True
self.assertTrue(check(rows,True)['approved'])
def test_github_comment_does_not_clear_changes_requested(self):
rows=[dict(id=1,state='CHANGES_REQUESTED',user={'login':'bob'},commit_id='a'),
dict(id=2,state='COMMENTED',user={'login':'bob'},commit_id='a'),
dict(id=3,state='APPROVED',user={'login':'alice'},commit_id='a')]
self.assertEqual(self.api.reviews(rows,'a','author',provider='github')['decision'],'changes_requested')
def test_gitea_capped_pages_include_late_rejection(self):
import urllib.parse
c = self.api.Client('git.example.test', 'owner/repo'); c.provider = 'gitea'
rows = [dict(id=i, state='APPROVED', user={'login':'alice'}, commit_id='a') for i in range(1, 120)]
rows.append(dict(id=120, state='REQUEST_CHANGES', user={'login':'bob'}, commit_id='a'))
def http(path):
page = int(urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)['page'][0])
return rows[(page-1)*50:page*50]
c.http = http
result = c.pages('/reviews')
self.assertEqual(len(result), 120)
self.assertEqual(self.api.reviews(result, 'a', 'author')['decision'], 'changes_requested')
def test_gitea_empty_write_and_post_merge_moved_base(self):
import os, types
from unittest.mock import patch
c = self.api.Client('git.example.test', 'owner/repo'); c.provider='gitea'; c.version='28.2.0'
c.token='synthetic'; c.actor_verified=True
c.transport=lambda *args: types.SimpleNamespace(returncode=0,stdout=b'HTTP/1.1 200 OK\r\n\r\n')
original = {'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
after = {'head':dict(original['head']), 'base':dict(original['base'],sha='d'*40), 'merged':True, 'merge_commit_sha':'d'*40}
reads=iter([original, original, after])
c.pr=lambda number: next(reads)
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
with patch.dict(os.environ,MOSAIC_GIT_IDENTITY='fixture'), patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
def test_gitea_post_merge_base_tip_can_move(self):
from unittest.mock import patch
c=self.api.Client('git.example.test','owner/repo'); c.provider='gitea'; c.version='28.2.0'
original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
after={'head':dict(original['head']),'base':dict(original['base'],sha='d'*40),'merged':True,'merge_commit_sha':'d'*40}
reads=iter([original,original,after]); c.pr=lambda number:next(reads)
c.http=lambda *args,**kwargs: None
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
def test_gitea_protection_whitelist_requires_official_approval(self):
c = self.api.Client('git.example.test','owner/repo'); c.provider='gitea'
c.pages = lambda path: [dict(branch_name='main', required_approvals=1, enable_status_check=True, status_check_contexts=['build'], enable_approvals_whitelist=True)]
self.api.protection(c,'main')
rows=[dict(id=1,state='APPROVED',user={'login':'alice'},commit_id='a',official=False)]
self.assertFalse(self.api.reviews(rows,'a','author',1,c.provider,c.review_official_required)['approved'])
def test_github_merge_json_and_invalid_readbacks(self):
import copy
from unittest.mock import patch
original={'head':{'sha':'a'*40,'ref':'topic','repo':{'full_name':'owner/repo'}}, 'base':{'sha':'b'*40,'ref':'main','repo':{'full_name':'owner/repo'}}}
status=dict(base_ref='main',base_sha='b'*40,base_repository='owner/repo',head_ref='topic',head_sha='a'*40,head_repository='owner/repo',ready=True,blockers=[])
for mutation in ('valid','bad_sha','bad_sha_numeric','retarget'):
with self.subTest(mutation=mutation):
c=self.api.Client('github.com','owner/repo'); c.provider='github'; c.version='documented'
after=copy.deepcopy(original); after.update(merged=True,merge_commit_sha='d'*40)
if mutation=='bad_sha': after['merge_commit_sha']='bad'
if mutation=='bad_sha_numeric': after['merge_commit_sha']=123
if mutation=='retarget': after['base']['ref']='other'
reads=iter([original,original,after]); c.pr=lambda number:next(reads)
c.http=lambda path,method='GET',body=None: {'merged':True,'sha':'d'*40}
with patch.object(self.api,'declared_method',return_value='squash'), patch.object(self.api,'forge_method'), patch.object(self.api,'snapshot',return_value=status), patch.object(self.api,'queue_guard'):
if mutation=='valid': self.assertEqual(self.api.merge(c,1,None,'a'*40)['merge_sha'],'d'*40)
else:
with self.assertRaises(self.api.Failure) as caught: self.api.merge(c,1,None,'a'*40)
self.assertEqual(caught.exception.outcome,'unknown')
class SnapshotContract(Contract):
def fake(self, race=False, missing=False):
api = self.api
@@ -14,7 +14,7 @@ class Policy(unittest.TestCase):
if policy is not None:
doc['merge'] = policy
path.write_text(json.dumps(doc))
args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base]
args = ['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo','owner/repo','--base-repo','owner/repo']
if method:
args += ['--method',method]
p = subprocess.run(args,capture_output=True,text=True)
@@ -26,6 +26,157 @@ class Policy(unittest.TestCase):
policy = dict(allowed_methods=['squash','merge'],default_method='squash',shapes={'absorb':'merge'})
self.assertEqual(self.resolve(policy,head='stable')[1].get('method'),'merge')
self.assertEqual(self.resolve(policy,method='squash',head='stable')[0],3)
def test_fork_branch_named_trunk_cannot_promote(self):
with tempfile.TemporaryDirectory() as directory:
path=pathlib.Path(directory)/'repo.json'
path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release')))
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','next','--base','main','--head-repo','alice/stack','--base-repo','owner/stack']
result=subprocess.run(args,capture_output=True,text=True)
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
args[-3]='owner/stack'
result=subprocess.run(args,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
def test_fork_release_branch_is_task_not_absorb(self):
with tempfile.TemporaryDirectory() as directory:
path=pathlib.Path(directory)/'repo.json'
path.write_text(json.dumps(dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge','squash'],default_method='squash',shapes={'absorb':'merge'}))))
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head','main','--base','next','--head-repo','alice/stack','--base-repo','owner/stack']
result=subprocess.run(args,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(json.loads(result.stdout)['shape'],'task')
self.assertEqual(json.loads(result.stdout)['method'],'squash')
def test_adapter_rejects_fork_promotion(self):
import os, importlib.util
spec=importlib.util.spec_from_file_location('fork_policy',HERE/'forge-api.py')
api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api)
with tempfile.TemporaryDirectory() as directory:
root=pathlib.Path(directory); (root/'.mosaic').mkdir()
def git(*args):
return subprocess.run(['git',*args],cwd=root,capture_output=True,text=True,check=True).stdout.strip()
git('init','-q'); git('remote','add','origin','https://git.example.test/owner/stack.git')
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://git.example.test/owner/stack',canonical_clone='host:/src/project')
(root/'.mosaic/repo.json').write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','policy')
p={'head':{'ref':'next','repo':{'full_name':'alice/stack'}},'base':{'ref':'main','sha':git('rev-parse','HEAD'),'repo':{'full_name':'owner/stack'}}}
c=api.Client('git.example.test','owner/stack'); old=os.getcwd()
try:
os.chdir(root)
with self.assertRaises(api.Failure) as caught: api.declared_method(c,p,None)
self.assertEqual(caught.exception.code,3)
p['head']['repo']['full_name']='owner/stack'
self.assertEqual(api.declared_method(c,p,None),'squash')
finally: os.chdir(old)
def test_branch_precheck_still_requires_absorb_mapping(self):
with tempfile.TemporaryDirectory() as directory:
path=pathlib.Path(directory)/'repo.json'
doc=dict(schema_version=3,integration_trunk='integration',release_branch='stable',flow='trunk-release')
def check():
return subprocess.run(['bash','-c','source "$1/repo-decl.sh"; DECL_STATE=valid; DECL_FLOW=trunk-release; DECL_TRUNK=integration; DECL_RELEASE=stable; DECL_FILE="$2"; repo_decl_check_transition stable integration','fixture',str(HERE),str(path)],capture_output=True,text=True)
path.write_text(json.dumps(doc))
self.assertNotEqual(check().returncode,0)
doc['merge']=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'})
path.write_text(json.dumps(doc))
self.assertEqual(check().returncode,0)
def test_missing_repository_identity_refuses_promotion_and_absorb(self):
with tempfile.TemporaryDirectory() as directory:
path=pathlib.Path(directory)/'repo.json'
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',merge=dict(allowed_methods=['merge'],default_method='merge',shapes={'absorb':'merge'}))
path.write_text(json.dumps(doc))
for head,base in [('next','main'),('main','next')]:
for head_repo,base_repo in [('', 'owner/repo'),('owner/repo',''),('','')]:
with self.subTest(head=head,head_repo=head_repo,base_repo=base_repo):
args=['python3','-I',str(HERE/'merge-policy.py'),str(path),'--head',head,'--base',base,'--head-repo',head_repo,'--base-repo',base_repo]
result=subprocess.run(args,capture_output=True,text=True)
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
def test_legacy_wrapper_uses_pinned_base_not_worktree_merge_policy(self):
import os, shutil
with tempfile.TemporaryDirectory() as directory:
root=pathlib.Path(directory); repo=root/'repo'; repo.mkdir()
git_tools=root/'tools/git'; git_tools.mkdir(parents=True)
structure=root/'tools/structure'; structure.mkdir()
for name in ['pr-merge.sh','repo-decl.sh','merge-policy.py']:
shutil.copy(HERE/name,git_tools/name)
shutil.copy(HERE.parent/'structure/validate-repo-json.sh',structure/'validate-repo-json.sh')
(git_tools/'detect-platform.sh').write_text('detect_platform() { echo github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\n')
def git(*args):
return subprocess.run(['git',*args],cwd=repo,capture_output=True,text=True,check=True).stdout.strip()
git('init','-q'); git('remote','add','origin','https://github.com/owner/repo')
(repo/'.mosaic').mkdir()
doc=dict(schema_version=3,integration_trunk='next',release_branch='main',flow='trunk-release',canonical_remote='https://github.com/owner/repo',canonical_clone='host:/src/repo',merge=dict(allowed_methods=['merge'],default_method='merge'))
path=repo/'.mosaic/repo.json'; path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base policy')
base=git('rev-parse','HEAD'); del doc['merge']; path.write_text(json.dumps(doc))
metadata=dict(baseRefName='next',baseRefOid=base,baseRepository={'name':'repo'},baseRepositoryOwner={'login':'owner'},headRefName='task',headRefOid='a'*40,headRepository={'name':'repo'},headRepositoryOwner={'login':'owner'})
script=git_tools/'pr-metadata.sh'; script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n"); script.chmod(0o755)
env=dict(os.environ,HOME=str(root),GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1')
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
self.assertEqual(result.returncode,3,result.stdout+result.stderr)
self.assertIn('POLICY-REFUSED',result.stderr)
# A missing declaration helper must not bypass a base merge policy.
(git_tools/'repo-decl.sh').unlink()
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
shutil.copy(HERE/'repo-decl.sh',git_tools/'repo-decl.sh')
# An uncommitted local merge policy cannot override a base without one.
git('add','.mosaic/repo.json')
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base without merge policy')
metadata['baseRefOid']=git('rev-parse','HEAD')
doc['merge']=dict(allowed_methods=['merge'],default_method='merge')
path.write_text(json.dumps(doc))
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
self.assertIn('Dry run:',result.stdout)
# A merge default cannot silently become squash, while an explicit
# squash remains allowed when the base policy permits it.
doc['merge']=dict(allowed_methods=['squash','merge'],default_method='merge')
path.write_text(json.dumps(doc)); git('add','.mosaic/repo.json')
git('-c','user.name=Fixture','-c','[email protected]','commit','-qm','base merge default')
metadata['baseRefOid']=git('rev-parse','HEAD')
del doc['merge']; path.write_text(json.dumps(doc))
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run','-m','squash'],cwd=repo,env=env,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
# Without an observed base SHA there is no revision to inspect.
metadata.pop('baseRefOid')
script.write_text("#!/bin/sh\nprintf '%s\\n' "+repr(json.dumps(metadata))+"\n")
result=subprocess.run(['bash',str(git_tools/'pr-merge.sh'),'-n','1','--dry-run'],cwd=repo,env=env,capture_output=True,text=True)
self.assertNotEqual(result.returncode,0,result.stdout+result.stderr)
def test_github_metadata_reads_pr_repository_identities(self):
import os, shutil
with tempfile.TemporaryDirectory() as directory:
root=pathlib.Path(directory); tools=root/'tools'; tools.mkdir()
shutil.copy(HERE/'pr-metadata.sh',tools/'pr-metadata.sh')
(tools/'detect-platform.sh').write_text('detect_platform() { PLATFORM=github; }\nget_repo_owner() { echo owner; }\nget_repo_name() { echo repo; }\nget_remote_host() { echo github.com; }\n')
gh=root/'gh'
gh.write_text("""#!/usr/bin/env python3
import json,sys
args=sys.argv[1:]
if args[:2]==['pr','view']:
requested=args[args.index('--json')+1].split(',')
if 'baseRepository' in requested or 'baseRepositoryOwner' in requested: sys.exit(2)
print(json.dumps({'number':1,'baseRefOid':'b'*40}))
elif args[0]=='api' and '/repos/owner/repo/pulls/1' in args:
print(json.dumps({'number':1,'base':{'sha':'b'*40,'ref':'next','repo':{'full_name':'owner/repo'}},'head':{'sha':'a'*40,'ref':'task','repo':{'full_name':'fork/repo'}}}))
else: sys.exit(3)
""")
gh.chmod(0o755)
env=dict(os.environ,PATH=str(root)+os.pathsep+os.environ['PATH'],HOME=str(root))
result=subprocess.run(['bash',str(tools/'pr-metadata.sh'),'-n','1'],env=env,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
data=json.loads(result.stdout)
self.assertEqual(data['baseRefOid'],'b'*40)
self.assertEqual(data['baseRepository'],'owner/repo')
self.assertEqual(data['headRepository'],'fork/repo')
def test_invalid_does_not_fall_back(self):
for policy in [{}, {'allowed_methods':['force_merge'],'default_method':'force_merge'}, {'allowed_methods':['squash'],'default_method':'merge'}, {'allowed_methods':['squash'],'default_method':'squash','unexpected':True}]:
self.assertEqual(self.resolve(policy)[0],6)
@@ -23,7 +23,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -21,7 +21,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -93,7 +93,7 @@ if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef01234
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0
fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
@@ -21,7 +21,7 @@ make_fixture() {
local base_branch="${3:-main}"
cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"$base_branch","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH
cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
@@ -62,7 +62,7 @@ case "${MOSAIC_TEST_COMMITS_MODE:?}" in
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
printf '{"number":42,"title":"%s","author":"poster","baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
SH
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
@@ -19,7 +19,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
printf '%s\n' '{"baseRefOid":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -86,7 +86,7 @@ cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
"state": "open",
"user": {"login": "edith"},
"head": {"ref": "edith/t_39ce717c-authentik-smoke-gate"},
"base": {"ref": "main"},
"base": {"ref": "main", "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
"labels": [{"name": "ci"}],
"assignees": [{"login": "edith"}],
"html_url": "https://git.uscllc.com/USC/uconnect/pulls/1905"
@@ -291,6 +291,8 @@ expected_head = sys.argv[2]
assert data["number"] == expected_number, data
assert data["baseRefName"] == "main", data
assert data["headRefName"] == expected_head, data
if expected_number == 1905:
assert data["baseRefOid"] == "b" * 40, data
PY
}
@@ -259,9 +259,13 @@ STUB
echo "== C. pr-merge transition validation (no hardcoded targets) =="
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
git -C "$1" add -A -- .mosaic
git -C "$1" diff --cached --quiet || git -C "$1" commit -q -m 'pinned base declaration'
local base_sha
base_sha=$(git -C "$1" rev-parse HEAD)
cat > "$1/tools/git/pr-metadata.sh" <<EOF
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
printf '%s\n' '{"baseRefName":"$2","baseRefOid":"$base_sha","baseRepository":"acme/widgets","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
EOF
chmod +x "$1/tools/git/pr-metadata.sh"
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
@@ -272,7 +276,7 @@ EOF
mkdir -p "$1/stub"
mkmetadata "$1"
M_RC=0
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" TMPDIR="$SB" GITEA_TOKEN=stub-token \
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
M_ERR="$(cat "$1/merr.txt")"
}