@bridge_gpt/mcp-server 0.2.59 → 0.2.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +53 -2
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +61 -0
- package/build/conductor/merge-execution-eligibility.js +129 -0
- package/build/conductor/readiness-sections.js +159 -2
- package/build/conductor/readiness.js +95 -14
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +16 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +183 -9
- package/build/drive-epic.js +23 -0
- package/build/epic-implementer-bin.js +10 -10
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +31 -0
- package/build/executor/credentials.js +11 -0
- package/build/executor/env.js +60 -0
- package/build/executor/index.js +21 -4
- package/build/executor/job-runner.js +126 -0
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +5 -0
- package/build/index.js +194 -179
- package/build/install-bridge-conductor.js +201 -8
- package/build/install-bridge.js +356 -30
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +133 -0
- package/build/merge-workflow-drift.js +66 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +572 -0
- package/build/pipelines.generated.js +2 -2
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +163 -9
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- package/build/plan-epic-conductor-eligibility.js +0 -213
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Epic Implementer merge-workflow renderer (BAPI-1136, A2 slice 4).
|
|
3
|
+
*
|
|
4
|
+
* This module renders the ONE GitHub Actions workflow that performs the
|
|
5
|
+
* child-pull-request merge on the customer's own infrastructure, with the
|
|
6
|
+
* ephemeral `GITHUB_TOKEN` supplying the `contents: write` scope Bridge
|
|
7
|
+
* deliberately does not hold. It is the customer-side WRITE ACTOR of the A2
|
|
8
|
+
* "infra floors & write authority" epic.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a TypeScript renderer and not a bundled YAML asset
|
|
11
|
+
*
|
|
12
|
+
* The ticket text asked for a template asset copied by
|
|
13
|
+
* `mcp_server/scripts/bundle-assets.js`. The review rejected that in favour of
|
|
14
|
+
* the repository's own established precedent (decision E-19):
|
|
15
|
+
* `claude-review-workflow.ts` already renders a workflow as compiled TypeScript,
|
|
16
|
+
* `tsc` already ships it in the published package, and a second mechanism for a
|
|
17
|
+
* single template would add a `*.generated.ts` that must never be hand-edited
|
|
18
|
+
* for no benefit. So this file is the workflow's ONLY byte source — nothing in
|
|
19
|
+
* `bundle-assets.js` changes.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the renderer takes no parameters
|
|
22
|
+
*
|
|
23
|
+
* {@link renderEpicImplementerMergeWorkflow} is input-free and deterministic.
|
|
24
|
+
* The claude-review renderer interpolates org-specific literals because its
|
|
25
|
+
* template genuinely varies per customer; this one does not. Every
|
|
26
|
+
* customer-specific value the merge actuator needs — the Bridge API key, the
|
|
27
|
+
* base URL, the attempt's action key and lease — arrives at RUN time through a
|
|
28
|
+
* repository secret, a repository variable, or a dispatch input. If a customer
|
|
29
|
+
* value could alter the canonical bytes, then "the workflow on the default
|
|
30
|
+
* branch" would no longer be one reviewable artifact with one identity, and the
|
|
31
|
+
* byte-pin that makes drift detectable would be meaningless.
|
|
32
|
+
*
|
|
33
|
+
* ## The security shape, in one paragraph
|
|
34
|
+
*
|
|
35
|
+
* `workflow_dispatch` runs the workflow definition from the TRUSTED DEFAULT
|
|
36
|
+
* BRANCH, so a pull request that edits this file cannot change the version that
|
|
37
|
+
* actually runs against it. The two dispatch inputs are a wake-up and a lease
|
|
38
|
+
* correlator; they authorize nothing. Authorization is the durable, SHA-bound
|
|
39
|
+
* merge command the job fetches from Bridge, presenting the repository's own
|
|
40
|
+
* API-key secret and the per-attempt lease secret. Nothing is ever checked out,
|
|
41
|
+
* no third-party action runs, the merge carries an expected-head-SHA
|
|
42
|
+
* precondition, a fork-origin head is refused, and a base that is the repository
|
|
43
|
+
* default branch is refused outright.
|
|
44
|
+
*
|
|
45
|
+
* ## Secret discipline
|
|
46
|
+
*
|
|
47
|
+
* The rendered script never enables shell tracing, never echoes either dispatch
|
|
48
|
+
* input, never prints a response body, and never writes a credential or the
|
|
49
|
+
* lease to a workflow output, a step summary, or `$GITHUB_ENV`. The API key and
|
|
50
|
+
* the lease reach `curl` through a `umask 077` config file and a request body
|
|
51
|
+
* that `jq` builds FROM THE ENVIRONMENT, so neither is ever an argv element
|
|
52
|
+
* visible to another process in the runner; both files are removed by an `EXIT`
|
|
53
|
+
* trap. `gh` authenticates from `GH_TOKEN` in the environment for the same
|
|
54
|
+
* reason.
|
|
55
|
+
*/
|
|
56
|
+
import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, MERGE_WORKFLOW_VERSION_MARKER_KEY, } from "./conductor/merge-execution-contract.js";
|
|
57
|
+
import { MERGE_COMMAND_FETCH_PATH, MERGE_WORKFLOW_API_KEY_SECRET_NAME, MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME, MERGE_WORKFLOW_DEFAULT_BASE_URL, } from "./merge-workflow-config.js";
|
|
58
|
+
/**
|
|
59
|
+
* Every refusal the rendered workflow can emit, as a closed vocabulary.
|
|
60
|
+
*
|
|
61
|
+
* A refusal is always one of these tokens — never raw `curl`, `jq`, or `gh`
|
|
62
|
+
* output, and never an HTTP body. Raw subprocess text is unbounded and can echo
|
|
63
|
+
* a URL, a header, or provider detail into a log that is world-readable on a
|
|
64
|
+
* public repository.
|
|
65
|
+
*
|
|
66
|
+
* Exported so the static safety tests can assert the rendered template mentions
|
|
67
|
+
* exactly these and nothing else.
|
|
68
|
+
*/
|
|
69
|
+
export const MERGE_WORKFLOW_REFUSAL_REASONS = [
|
|
70
|
+
/** The repository-level Bridge API key secret is absent or empty. */
|
|
71
|
+
"bridge_api_key_secret_missing",
|
|
72
|
+
/** The durable merge command could not be fetched (includes the 404 the absent sibling child returns). */
|
|
73
|
+
"merge_command_unavailable",
|
|
74
|
+
/** The command was fetched but is unusable: missing fields, or an execution mode this actuator does not perform. */
|
|
75
|
+
"merge_command_rejected",
|
|
76
|
+
/** The command names a repository or action key that is not this workflow's live context. */
|
|
77
|
+
"repository_identity_mismatch",
|
|
78
|
+
/** The repository default branch could not be read, so the base guard cannot be applied. */
|
|
79
|
+
"default_branch_unresolved",
|
|
80
|
+
/** The pull request could not be read from live provider state. */
|
|
81
|
+
"pr_read_failed",
|
|
82
|
+
/** The pull request is not open. */
|
|
83
|
+
"pr_not_open",
|
|
84
|
+
/** The head is a fork or an otherwise unverifiable cross-repository ref. */
|
|
85
|
+
"fork_origin_denied",
|
|
86
|
+
/** The pull request targets the repository default branch. Never retried. */
|
|
87
|
+
"base_is_default_branch",
|
|
88
|
+
/** The live head moved away from the command's expected head SHA. Never retried. */
|
|
89
|
+
"head_sha_drift",
|
|
90
|
+
/** `gh pr merge` failed and the read-back did not show the merge landed. */
|
|
91
|
+
"gh_merge_failed",
|
|
92
|
+
/** The merge attempt's outcome could not be classified from the read-back. */
|
|
93
|
+
"merge_outcome_ambiguous",
|
|
94
|
+
/** The merge landed but its completion report was not accepted; the reconciler recovers. */
|
|
95
|
+
"completion_unreported",
|
|
96
|
+
];
|
|
97
|
+
/**
|
|
98
|
+
* Build a GitHub Actions expression without writing `${` in TypeScript source.
|
|
99
|
+
*
|
|
100
|
+
* The rendered lines below are ordinary double-quoted strings, not template
|
|
101
|
+
* literals, so this is belt-and-braces: it keeps every expression marker in one
|
|
102
|
+
* place and makes a grep for "which expressions does this template evaluate"
|
|
103
|
+
* answerable.
|
|
104
|
+
*/
|
|
105
|
+
const gh = (expr) => "${{ " + expr + " }}";
|
|
106
|
+
const SECRET_EXPR = gh(`secrets.${MERGE_WORKFLOW_API_KEY_SECRET_NAME}`);
|
|
107
|
+
const BASE_URL_EXPR = gh(`vars.${MERGE_WORKFLOW_BASE_URL_VARIABLE_NAME} || '${MERGE_WORKFLOW_DEFAULT_BASE_URL}'`);
|
|
108
|
+
/**
|
|
109
|
+
* Render the canonical merge workflow.
|
|
110
|
+
*
|
|
111
|
+
* Deterministic and input-free: two calls in the same build return byte-identical
|
|
112
|
+
* output, and no customer value can alter a single byte. The result is
|
|
113
|
+
* newline-terminated.
|
|
114
|
+
*/
|
|
115
|
+
export function renderEpicImplementerMergeWorkflow() {
|
|
116
|
+
const lines = [
|
|
117
|
+
"# ===========================================================================",
|
|
118
|
+
"# Epic Implementer merge actuator — MANAGED FILE",
|
|
119
|
+
"#",
|
|
120
|
+
"# Generated by Bridge (`" + MERGE_WORKFLOW_PATH + "`). Do not hand-edit: the",
|
|
121
|
+
"# installer compares this file byte for byte against the version it ships and",
|
|
122
|
+
"# refuses to touch a modified or unrecognized copy.",
|
|
123
|
+
"#",
|
|
124
|
+
"# WHAT THIS WORKFLOW IS",
|
|
125
|
+
"#",
|
|
126
|
+
"# It performs the GitHub merge for an Epic Implementer child pull request that",
|
|
127
|
+
"# Bridge has already approved. Bridge's own GitHub App installation is",
|
|
128
|
+
"# `contents: read` and cannot merge; the ephemeral GITHUB_TOKEN issued to this",
|
|
129
|
+
"# job can. That is the entire reason this file exists, and it is why the file",
|
|
130
|
+
"# is worth a careful human review before it is merged.",
|
|
131
|
+
"#",
|
|
132
|
+
"# TRUSTED-BRANCH EXECUTION",
|
|
133
|
+
"#",
|
|
134
|
+
"# `workflow_dispatch` ALWAYS runs the workflow definition that is on the",
|
|
135
|
+
"# repository's DEFAULT BRANCH. A pull request that edits this file therefore",
|
|
136
|
+
"# cannot change the version of this workflow that runs against it — the copy",
|
|
137
|
+
"# under review is inert until it is merged into the default branch. Protect",
|
|
138
|
+
"# this path with a code-owner review rule so that merge is a deliberate act.",
|
|
139
|
+
"#",
|
|
140
|
+
"# THE TWO DISPATCH INPUTS AUTHORIZE NOTHING",
|
|
141
|
+
"#",
|
|
142
|
+
"# `action_key` is a wake-up: it names which approved attempt this run is for.",
|
|
143
|
+
"# `lease_owner` is lease-correlation data: it proves to Bridge that this run is",
|
|
144
|
+
"# the live attempt. Neither grants permission to merge anything, neither is",
|
|
145
|
+
"# trusted as the source of what to merge, and neither is ever echoed to the",
|
|
146
|
+
"# log, a step output, a job summary, or $GITHUB_ENV. Everything this job acts",
|
|
147
|
+
"# on comes from the durable, SHA-bound merge command it fetches from Bridge and",
|
|
148
|
+
"# then re-validates against live provider state.",
|
|
149
|
+
"#",
|
|
150
|
+
"# WHAT IT NEVER DOES",
|
|
151
|
+
"#",
|
|
152
|
+
"# * It never checks out the repository, and never runs pull-request content.",
|
|
153
|
+
"# * It uses no `uses:` step and no third-party action — only runner-provided",
|
|
154
|
+
"# bash, curl, jq, and the GitHub CLI.",
|
|
155
|
+
"# * It never merges into the repository default branch.",
|
|
156
|
+
"# * It never merges a fork-origin or otherwise unverifiable head.",
|
|
157
|
+
"# * It never merges a head that has moved from the approved SHA.",
|
|
158
|
+
"#",
|
|
159
|
+
"# RESIDUAL RACE (base retargeting)",
|
|
160
|
+
"#",
|
|
161
|
+
"# Base selection is not atomic with the provider merge. This job validates the",
|
|
162
|
+
"# base before merging, and Bridge's completion path re-reads the pull request",
|
|
163
|
+
"# and re-applies the same base guard afterwards. A base retargeted inside that",
|
|
164
|
+
"# window is detected on the completion side and refused there, which leaves the",
|
|
165
|
+
"# attempt recorded as refused rather than silently accepted. Recovering from a",
|
|
166
|
+
"# detected retarget race is a repository-administrator action, not a retry.",
|
|
167
|
+
"# ===========================================================================",
|
|
168
|
+
"",
|
|
169
|
+
"name: Epic Implementer merge",
|
|
170
|
+
"",
|
|
171
|
+
"# The machine-readable contract version. Bridge's eligibility probe requires",
|
|
172
|
+
"# EXACT equality with the version it understands; a newer file is a workflow it",
|
|
173
|
+
"# has never been taught to reason about and is refused rather than dispatched.",
|
|
174
|
+
"#",
|
|
175
|
+
"# WHY IT LIVES UNDER `env:` AND NOT AT TOP LEVEL. GitHub Actions validates a",
|
|
176
|
+
"# workflow against a CLOSED top-level schema — name, run-name, on, permissions,",
|
|
177
|
+
"# env, defaults, concurrency, jobs. An unknown top-level key is not a tolerated",
|
|
178
|
+
"# extension: it makes the whole file refuse to load with \"Invalid workflow",
|
|
179
|
+
"# file\", which would leave a permanently red annotation on the customer's",
|
|
180
|
+
"# default branch AND never register the `workflow_dispatch` trigger, so the",
|
|
181
|
+
"# actuator could not perform a single merge. The failure would also be QUIET:",
|
|
182
|
+
"# Bridge parses this file with PyYAML, which reads an unknown top-level key",
|
|
183
|
+
"# happily, so the eligibility probe would report the repository ready and the",
|
|
184
|
+
"# breakage would surface only as a dispatch error. Bridge's own",
|
|
185
|
+
"# `_resolve_version_marker` reads the marker from the top level OR from `env`,",
|
|
186
|
+
"# and declaring it in exactly one of them — this one — is both schema-legal and",
|
|
187
|
+
"# unambiguous. Declaring it in BOTH is what makes a document unreadable.",
|
|
188
|
+
"env:",
|
|
189
|
+
" " + MERGE_WORKFLOW_VERSION_MARKER_KEY + ": " + String(MERGE_WORKFLOW_EXPECTED_VERSION),
|
|
190
|
+
"",
|
|
191
|
+
"on:",
|
|
192
|
+
" workflow_dispatch:",
|
|
193
|
+
" inputs:",
|
|
194
|
+
" action_key:",
|
|
195
|
+
" description: >-",
|
|
196
|
+
" Durable merge-action key naming the approved attempt. A wake-up and a",
|
|
197
|
+
" correlator only; it authorizes nothing by itself.",
|
|
198
|
+
" required: true",
|
|
199
|
+
" type: string",
|
|
200
|
+
" lease_owner:",
|
|
201
|
+
" description: >-",
|
|
202
|
+
" Per-attempt lease secret proving this run is the live attempt. Never",
|
|
203
|
+
" printed, never echoed, never written to an output.",
|
|
204
|
+
" required: true",
|
|
205
|
+
" type: string",
|
|
206
|
+
"",
|
|
207
|
+
"# Exactly the two scopes the merge needs, granted once at workflow level. No",
|
|
208
|
+
"# job overrides these, and no other permission is requested.",
|
|
209
|
+
"permissions:",
|
|
210
|
+
" contents: write",
|
|
211
|
+
" pull-requests: write",
|
|
212
|
+
"",
|
|
213
|
+
"# Grouped on the non-secret action key so two dispatches for the SAME attempt",
|
|
214
|
+
"# queue instead of racing. `cancel-in-progress: false` because cancelling a run",
|
|
215
|
+
"# that may already have merged would destroy the evidence. This is a courtesy,",
|
|
216
|
+
"# not a control: Bridge's single-use lease remains the authoritative guard",
|
|
217
|
+
"# against a second actuation.",
|
|
218
|
+
"concurrency:",
|
|
219
|
+
" group: epic-implementer-merge-" + gh("inputs.action_key"),
|
|
220
|
+
" cancel-in-progress: false",
|
|
221
|
+
"",
|
|
222
|
+
"jobs:",
|
|
223
|
+
" merge:",
|
|
224
|
+
" name: Merge the approved Epic Implementer pull request",
|
|
225
|
+
" runs-on: ubuntu-latest",
|
|
226
|
+
" timeout-minutes: 10",
|
|
227
|
+
" env:",
|
|
228
|
+
" BAPI_BASE_URL: " + BASE_URL_EXPR,
|
|
229
|
+
" BAPI_API_KEY: " + SECRET_EXPR,
|
|
230
|
+
" ACTION_KEY: " + gh("inputs.action_key"),
|
|
231
|
+
" LEASE_OWNER: " + gh("inputs.lease_owner"),
|
|
232
|
+
" REPO_FULL_NAME: " + gh("github.repository"),
|
|
233
|
+
" GH_TOKEN: " + gh("github.token"),
|
|
234
|
+
" GH_PROMPT_DISABLED: '1'",
|
|
235
|
+
" GH_NO_UPDATE_NOTIFIER: '1'",
|
|
236
|
+
" steps:",
|
|
237
|
+
" # ------------------------------------------------------------------",
|
|
238
|
+
" # 1. Fail before anything else when the repository secret is absent.",
|
|
239
|
+
" # Reported as a named reason so an operator sees the fix, not a",
|
|
240
|
+
" # 404 from an unauthenticated call three steps later.",
|
|
241
|
+
" # ------------------------------------------------------------------",
|
|
242
|
+
" - name: Require the Bridge API key secret",
|
|
243
|
+
" shell: bash",
|
|
244
|
+
" run: |",
|
|
245
|
+
" set -uo pipefail",
|
|
246
|
+
" set +x",
|
|
247
|
+
' if [ -z "${BAPI_API_KEY:-}" ]; then',
|
|
248
|
+
' echo "REFUSAL_REASON=bridge_api_key_secret_missing" >> "$GITHUB_ENV"',
|
|
249
|
+
' echo "::error::Repository secret ' +
|
|
250
|
+
MERGE_WORKFLOW_API_KEY_SECRET_NAME +
|
|
251
|
+
' is not set. Create it with a Bridge API key for this repository."',
|
|
252
|
+
" exit 1",
|
|
253
|
+
" fi",
|
|
254
|
+
"",
|
|
255
|
+
" # ------------------------------------------------------------------",
|
|
256
|
+
" # 2. Fetch the durable, SHA-bound merge command. This is the ONLY",
|
|
257
|
+
" # authorization in the whole job. The API key reaches curl through a",
|
|
258
|
+
" # 0600 config file and the lease through a jq-built body, so neither",
|
|
259
|
+
" # is ever an argv element; both files die with the step.",
|
|
260
|
+
" #",
|
|
261
|
+
" # A 404 here is the EXPECTED state until the Bridge-side durable",
|
|
262
|
+
" # command store ships. The job refuses without retrying and without",
|
|
263
|
+
" # touching the pull request, so this actuator is inert rather than",
|
|
264
|
+
" # dangerous while that sibling is outstanding.",
|
|
265
|
+
" # ------------------------------------------------------------------",
|
|
266
|
+
" - name: Fetch the durable merge command",
|
|
267
|
+
" shell: bash",
|
|
268
|
+
" run: |",
|
|
269
|
+
" set -uo pipefail",
|
|
270
|
+
" set +x",
|
|
271
|
+
" umask 077",
|
|
272
|
+
' curlrc="${RUNNER_TEMP}/bapi-merge.curlrc"',
|
|
273
|
+
' reqbody="${RUNNER_TEMP}/bapi-merge-request.json"',
|
|
274
|
+
' respbody="${RUNNER_TEMP}/bapi-merge-command.json"',
|
|
275
|
+
" trap 'rm -f \"$curlrc\" \"$reqbody\" \"$respbody\"' EXIT",
|
|
276
|
+
" # Both reads are from the ENVIRONMENT, never from argv or a here-string.",
|
|
277
|
+
" jq -rn 'env.BAPI_API_KEY | \"header = \\\"X-API-Key: \\(.)\\\"\"' > \"$curlrc\"",
|
|
278
|
+
" jq -n '{repo_name: (env.ACTION_KEY | split(\":\") | .[1] // \"\"), action_key: env.ACTION_KEY, lease_owner: env.LEASE_OWNER}' > \"$reqbody\"",
|
|
279
|
+
" # --retry 0: every refusal this endpoint returns is deterministic, and",
|
|
280
|
+
" # retrying a lease-bound POST risks a second billable attempt.",
|
|
281
|
+
" code=\"$(curl --silent --show-error --no-progress-meter --retry 0 \\",
|
|
282
|
+
" --max-time 30 \\",
|
|
283
|
+
' --config "$curlrc" \\',
|
|
284
|
+
" --header 'Content-Type: application/json' \\",
|
|
285
|
+
" --request POST \\",
|
|
286
|
+
' --data-binary "@$reqbody" \\',
|
|
287
|
+
' --output "$respbody" \\',
|
|
288
|
+
" --write-out '%{http_code}' \\",
|
|
289
|
+
' "${BAPI_BASE_URL%/}' +
|
|
290
|
+
MERGE_COMMAND_FETCH_PATH +
|
|
291
|
+
'" || echo "000")"',
|
|
292
|
+
' if [ "$code" != "200" ]; then',
|
|
293
|
+
' echo "REFUSAL_REASON=merge_command_unavailable" >> "$GITHUB_ENV"',
|
|
294
|
+
' echo "::error::Bridge did not return a durable merge command (HTTP $code)."',
|
|
295
|
+
" exit 1",
|
|
296
|
+
" fi",
|
|
297
|
+
" # Only non-secret projection fields are lifted into the job env. The",
|
|
298
|
+
" # response body itself is never printed and never survives this step.",
|
|
299
|
+
' cmd_action_key="$(jq -r \'.action_key // ""\' "$respbody")"',
|
|
300
|
+
' cmd_repo_name="$(jq -r \'.repo_name // ""\' "$respbody")"',
|
|
301
|
+
' cmd_repository="$(jq -r \'.github_repository // ""\' "$respbody")"',
|
|
302
|
+
' lease_expires_at="$(jq -r \'.lease_expires_at // ""\' "$respbody")"',
|
|
303
|
+
' pr_number="$(jq -r \'.pr_number // ""\' "$respbody")"',
|
|
304
|
+
' expected_head_sha="$(jq -r \'.expected_head_sha // ""\' "$respbody")"',
|
|
305
|
+
' merge_method="$(jq -r \'.merge_method // ""\' "$respbody")"',
|
|
306
|
+
' execution="$(jq -r \'.execution // ""\' "$respbody")"',
|
|
307
|
+
' case "$merge_method" in',
|
|
308
|
+
" squash|merge|rebase) ;;",
|
|
309
|
+
" *)",
|
|
310
|
+
' echo "REFUSAL_REASON=merge_command_rejected" >> "$GITHUB_ENV"',
|
|
311
|
+
' echo "::error::The merge command names an unsupported merge method."',
|
|
312
|
+
" exit 1",
|
|
313
|
+
" ;;",
|
|
314
|
+
" esac",
|
|
315
|
+
' if [ "$execution" != "github_actions" ]; then',
|
|
316
|
+
' echo "REFUSAL_REASON=merge_command_rejected" >> "$GITHUB_ENV"',
|
|
317
|
+
' echo "::error::The merge command was not issued for GitHub Actions execution."',
|
|
318
|
+
" exit 1",
|
|
319
|
+
" fi",
|
|
320
|
+
' case "$pr_number" in',
|
|
321
|
+
" ''|*[!0-9]*)",
|
|
322
|
+
' echo "REFUSAL_REASON=merge_command_rejected" >> "$GITHUB_ENV"',
|
|
323
|
+
' echo "::error::The merge command carries no usable pull request number."',
|
|
324
|
+
" exit 1",
|
|
325
|
+
" ;;",
|
|
326
|
+
" esac",
|
|
327
|
+
' if [ "${#expected_head_sha}" -ne 40 ]; then',
|
|
328
|
+
' echo "REFUSAL_REASON=merge_command_rejected" >> "$GITHUB_ENV"',
|
|
329
|
+
' echo "::error::The merge command carries no usable expected head SHA."',
|
|
330
|
+
" exit 1",
|
|
331
|
+
" fi",
|
|
332
|
+
" # An already-expired lease cannot be closed, so merging on it would",
|
|
333
|
+
" # perform a real write that no completion report could record. Refuse",
|
|
334
|
+
" # before the first provider read rather than after the merge.",
|
|
335
|
+
' if [ -n "$lease_expires_at" ]; then',
|
|
336
|
+
' expiry_epoch="$(date -u -d "$lease_expires_at" +%s 2>/dev/null || echo "")"',
|
|
337
|
+
' if [ -n "$expiry_epoch" ] && [ "$expiry_epoch" -le "$(date -u +%s)" ]; then',
|
|
338
|
+
' echo "REFUSAL_REASON=merge_command_rejected" >> "$GITHUB_ENV"',
|
|
339
|
+
' echo "::error::The merge command lease had already expired when this run started."',
|
|
340
|
+
" exit 1",
|
|
341
|
+
" fi",
|
|
342
|
+
" fi",
|
|
343
|
+
" # The live workflow context is authoritative. A command minted for a",
|
|
344
|
+
" # different repository, or for a different attempt, is refused here —",
|
|
345
|
+
" # before a single provider read is spent on it. Both repository",
|
|
346
|
+
" # identities are checked: the provider one against github.repository,",
|
|
347
|
+
" # and Bridge's own against the name carried inside the action key, so a",
|
|
348
|
+
" # command whose two namespaces disagree cannot actuate either.",
|
|
349
|
+
' key_repo_name="$(jq -rn \'env.ACTION_KEY | split(":") | .[1] // ""\')"',
|
|
350
|
+
' if [ "$cmd_repository" != "$REPO_FULL_NAME" ] \\',
|
|
351
|
+
' || [ "$cmd_action_key" != "$ACTION_KEY" ] \\',
|
|
352
|
+
' || [ "$cmd_repo_name" != "$key_repo_name" ]; then',
|
|
353
|
+
' echo "REFUSAL_REASON=repository_identity_mismatch" >> "$GITHUB_ENV"',
|
|
354
|
+
' echo "::error::The merge command does not match this repository and attempt."',
|
|
355
|
+
" exit 1",
|
|
356
|
+
" fi",
|
|
357
|
+
' {',
|
|
358
|
+
' echo "PR_NUMBER=$pr_number"',
|
|
359
|
+
' echo "EXPECTED_HEAD_SHA=$expected_head_sha"',
|
|
360
|
+
' echo "MERGE_METHOD=$merge_method"',
|
|
361
|
+
' } >> "$GITHUB_ENV"',
|
|
362
|
+
"",
|
|
363
|
+
" # ------------------------------------------------------------------",
|
|
364
|
+
" # 3. Read live provider state and apply every guard, in the order a",
|
|
365
|
+
" # cheap deterministic refusal comes before an expensive one. No ref",
|
|
366
|
+
" # is checked out at any point.",
|
|
367
|
+
" # ------------------------------------------------------------------",
|
|
368
|
+
" - name: Resolve the repository default branch",
|
|
369
|
+
" shell: bash",
|
|
370
|
+
" run: |",
|
|
371
|
+
" set -uo pipefail",
|
|
372
|
+
" set +x",
|
|
373
|
+
' default_branch="$(gh api "repos/$REPO_FULL_NAME" --jq \'.default_branch // ""\' 2>/dev/null || echo "")"',
|
|
374
|
+
' if [ -z "$default_branch" ]; then',
|
|
375
|
+
' echo "REFUSAL_REASON=default_branch_unresolved" >> "$GITHUB_ENV"',
|
|
376
|
+
' echo "::error::The repository default branch could not be read, so the base guard cannot be applied."',
|
|
377
|
+
" exit 1",
|
|
378
|
+
" fi",
|
|
379
|
+
' echo "DEFAULT_BRANCH=$default_branch" >> "$GITHUB_ENV"',
|
|
380
|
+
"",
|
|
381
|
+
" - name: Guard the pull request before merging",
|
|
382
|
+
" shell: bash",
|
|
383
|
+
" run: |",
|
|
384
|
+
" set -uo pipefail",
|
|
385
|
+
" set +x",
|
|
386
|
+
" pr_json=\"$(gh pr view \"$PR_NUMBER\" --repo \"$REPO_FULL_NAME\" \\",
|
|
387
|
+
" --json state,baseRefName,headRefOid,isCrossRepository,headRepositoryOwner,headRepository \\",
|
|
388
|
+
' 2>/dev/null || echo "")"',
|
|
389
|
+
' if [ -z "$pr_json" ]; then',
|
|
390
|
+
' echo "REFUSAL_REASON=pr_read_failed" >> "$GITHUB_ENV"',
|
|
391
|
+
' echo "::error::Pull request #$PR_NUMBER could not be read."',
|
|
392
|
+
" exit 1",
|
|
393
|
+
" fi",
|
|
394
|
+
' state="$(printf \'%s\' "$pr_json" | jq -r \'.state // ""\')"',
|
|
395
|
+
' base_ref="$(printf \'%s\' "$pr_json" | jq -r \'.baseRefName // ""\')"',
|
|
396
|
+
' head_sha="$(printf \'%s\' "$pr_json" | jq -r \'.headRefOid // ""\')"',
|
|
397
|
+
' cross_repo="$(printf \'%s\' "$pr_json" | jq -r \'.isCrossRepository // true\')"',
|
|
398
|
+
' head_repo="$(printf \'%s\' "$pr_json" | jq -r \'((.headRepositoryOwner.login // "") + "/" + (.headRepository.name // ""))\')"',
|
|
399
|
+
' if [ "$state" != "OPEN" ]; then',
|
|
400
|
+
' echo "REFUSAL_REASON=pr_not_open" >> "$GITHUB_ENV"',
|
|
401
|
+
' echo "::error::Pull request #$PR_NUMBER is not open."',
|
|
402
|
+
" exit 1",
|
|
403
|
+
" fi",
|
|
404
|
+
" # A fork head, or a head whose repository cannot be proven to be this",
|
|
405
|
+
" # one, is refused. `isCrossRepository` defaults to true above so an",
|
|
406
|
+
" # absent field fails closed rather than reading as same-repo.",
|
|
407
|
+
' if [ "$cross_repo" != "false" ] || [ "$head_repo" != "$REPO_FULL_NAME" ]; then',
|
|
408
|
+
' echo "REFUSAL_REASON=fork_origin_denied" >> "$GITHUB_ENV"',
|
|
409
|
+
' echo "::error::Pull request #$PR_NUMBER has a fork-origin or unverifiable head."',
|
|
410
|
+
" exit 1",
|
|
411
|
+
" fi",
|
|
412
|
+
" # The epic's AC-5 hard denial. Never retried: a default-branch base is",
|
|
413
|
+
" # a decision that has to change, not a condition that can clear.",
|
|
414
|
+
' if [ "$base_ref" = "$DEFAULT_BRANCH" ]; then',
|
|
415
|
+
' echo "REFUSAL_REASON=base_is_default_branch" >> "$GITHUB_ENV"',
|
|
416
|
+
' echo "::error::Pull request #$PR_NUMBER targets the repository default branch; this actuator never merges into it."',
|
|
417
|
+
" exit 1",
|
|
418
|
+
" fi",
|
|
419
|
+
" # Head drift. Also never retried — the approval was bound to a SHA.",
|
|
420
|
+
' if [ "$head_sha" != "$EXPECTED_HEAD_SHA" ]; then',
|
|
421
|
+
' echo "REFUSAL_REASON=head_sha_drift" >> "$GITHUB_ENV"',
|
|
422
|
+
' echo "::error::Pull request #$PR_NUMBER moved away from the approved head commit."',
|
|
423
|
+
" exit 1",
|
|
424
|
+
" fi",
|
|
425
|
+
"",
|
|
426
|
+
" # ------------------------------------------------------------------",
|
|
427
|
+
" # 4. The one privileged write. `--match-head-commit` re-asserts the",
|
|
428
|
+
" # expected head at the provider, so the guard above and the merge",
|
|
429
|
+
" # itself cannot disagree across the gap between them. The token",
|
|
430
|
+
" # reaches gh through GH_TOKEN in the environment, never argv.",
|
|
431
|
+
" # ------------------------------------------------------------------",
|
|
432
|
+
" - name: Merge the pull request",
|
|
433
|
+
" shell: bash",
|
|
434
|
+
" run: |",
|
|
435
|
+
" set -uo pipefail",
|
|
436
|
+
" set +x",
|
|
437
|
+
" if gh pr merge \"$PR_NUMBER\" --repo \"$REPO_FULL_NAME\" \\",
|
|
438
|
+
' "--$MERGE_METHOD" --match-head-commit "$EXPECTED_HEAD_SHA" >/dev/null 2>&1; then',
|
|
439
|
+
' echo "MERGE_ATTEMPT=accepted" >> "$GITHUB_ENV"',
|
|
440
|
+
" else",
|
|
441
|
+
' echo "MERGE_ATTEMPT=rejected" >> "$GITHUB_ENV"',
|
|
442
|
+
" fi",
|
|
443
|
+
"",
|
|
444
|
+
" # ------------------------------------------------------------------",
|
|
445
|
+
" # 5. Classify from a FRESH read rather than from the merge command's own",
|
|
446
|
+
" # exit status. A slow-but-successful merge that gh reported as a",
|
|
447
|
+
" # failure would otherwise be recorded as a failure and invite a",
|
|
448
|
+
" # second attempt at a pull request that is already merged.",
|
|
449
|
+
" # ------------------------------------------------------------------",
|
|
450
|
+
" - name: Classify the merge outcome",
|
|
451
|
+
" shell: bash",
|
|
452
|
+
" run: |",
|
|
453
|
+
" set -uo pipefail",
|
|
454
|
+
" set +x",
|
|
455
|
+
" pr_json=\"$(gh pr view \"$PR_NUMBER\" --repo \"$REPO_FULL_NAME\" \\",
|
|
456
|
+
' --json state,mergeCommit,headRefOid 2>/dev/null || echo "")"',
|
|
457
|
+
' if [ -z "$pr_json" ]; then',
|
|
458
|
+
' echo "REFUSAL_REASON=merge_outcome_ambiguous" >> "$GITHUB_ENV"',
|
|
459
|
+
' echo "::error::Pull request #$PR_NUMBER could not be re-read after the merge attempt."',
|
|
460
|
+
" exit 1",
|
|
461
|
+
" fi",
|
|
462
|
+
' state="$(printf \'%s\' "$pr_json" | jq -r \'.state // ""\')"',
|
|
463
|
+
' merge_sha="$(printf \'%s\' "$pr_json" | jq -r \'.mergeCommit.oid // ""\')"',
|
|
464
|
+
' if [ "$state" = "MERGED" ]; then',
|
|
465
|
+
' {',
|
|
466
|
+
' echo "MERGE_RESULT=merged"',
|
|
467
|
+
' echo "MERGE_SHA=$merge_sha"',
|
|
468
|
+
' } >> "$GITHUB_ENV"',
|
|
469
|
+
" exit 0",
|
|
470
|
+
" fi",
|
|
471
|
+
' if [ "${MERGE_ATTEMPT:-}" = "rejected" ]; then',
|
|
472
|
+
' echo "REFUSAL_REASON=gh_merge_failed" >> "$GITHUB_ENV"',
|
|
473
|
+
' echo "::error::The merge was refused by GitHub and pull request #$PR_NUMBER is still open."',
|
|
474
|
+
" exit 1",
|
|
475
|
+
" fi",
|
|
476
|
+
' echo "REFUSAL_REASON=merge_outcome_ambiguous" >> "$GITHUB_ENV"',
|
|
477
|
+
' echo "::error::The merge attempt for pull request #$PR_NUMBER could not be classified."',
|
|
478
|
+
" exit 1",
|
|
479
|
+
"",
|
|
480
|
+
" # ------------------------------------------------------------------",
|
|
481
|
+
" # 6. Report the final result. This runs only AFTER the merge/read-back",
|
|
482
|
+
" # sequence — it reports what happened and decides nothing. The lease",
|
|
483
|
+
" # secret travels in the completion contract's own `lease_owner` field",
|
|
484
|
+
" # and appears nowhere else.",
|
|
485
|
+
" # ------------------------------------------------------------------",
|
|
486
|
+
" - name: Report the merge result to Bridge",
|
|
487
|
+
" shell: bash",
|
|
488
|
+
" run: |",
|
|
489
|
+
" set -uo pipefail",
|
|
490
|
+
" set +x",
|
|
491
|
+
" umask 077",
|
|
492
|
+
' curlrc="${RUNNER_TEMP}/bapi-complete.curlrc"',
|
|
493
|
+
' reqbody="${RUNNER_TEMP}/bapi-complete.json"',
|
|
494
|
+
" trap 'rm -f \"$curlrc\" \"$reqbody\"' EXIT",
|
|
495
|
+
" jq -rn 'env.BAPI_API_KEY | \"header = \\\"X-API-Key: \\(.)\\\"\"' > \"$curlrc\"",
|
|
496
|
+
" jq -n '{repo_name: (env.ACTION_KEY | split(\":\") | .[1] // \"\"), action_key: env.ACTION_KEY, expected_head_sha: env.EXPECTED_HEAD_SHA, result: \"merged\", merge_sha: env.MERGE_SHA, lease_owner: env.LEASE_OWNER}' > \"$reqbody\"",
|
|
497
|
+
" code=\"$(curl --silent --show-error --no-progress-meter --retry 0 \\",
|
|
498
|
+
" --max-time 30 \\",
|
|
499
|
+
' --config "$curlrc" \\',
|
|
500
|
+
" --header 'Content-Type: application/json' \\",
|
|
501
|
+
" --request POST \\",
|
|
502
|
+
' --data-binary "@$reqbody" \\',
|
|
503
|
+
" --output /dev/null \\",
|
|
504
|
+
" --write-out '%{http_code}' \\",
|
|
505
|
+
' "${BAPI_BASE_URL%/}/vcs/pull-requests/$PR_NUMBER/merge/complete" || echo "000")"',
|
|
506
|
+
' if [ "$code" = "200" ]; then',
|
|
507
|
+
' echo "Reported the merge of pull request #$PR_NUMBER."',
|
|
508
|
+
" exit 0",
|
|
509
|
+
" fi",
|
|
510
|
+
" # A merged pull request whose report was not accepted is an UNREPORTED",
|
|
511
|
+
" # outcome, not an unmerged one. Bridge's reconciler recovers it, with",
|
|
512
|
+
" # the lease's own expiry as the backstop; a repository administrator",
|
|
513
|
+
" # only needs to intervene if that recovery does not close the row.",
|
|
514
|
+
' echo "::error::The merge of pull request #$PR_NUMBER landed but its completion report was not accepted (HTTP $code); Bridge will reconcile it."',
|
|
515
|
+
' echo "REFUSAL_REASON=completion_unreported" >> "$GITHUB_ENV"',
|
|
516
|
+
" exit 1",
|
|
517
|
+
"",
|
|
518
|
+
" # ------------------------------------------------------------------",
|
|
519
|
+
" # 7. Bounded failure report. Attempted for every denial that resolved",
|
|
520
|
+
" # enough identity to name the attempt — which means the command",
|
|
521
|
+
" # fetch succeeded. A failure BEFORE that point has no pull request",
|
|
522
|
+
" # number, so the completion route cannot be addressed at all; the",
|
|
523
|
+
" # lease expiry is the reconciler's backstop for that window.",
|
|
524
|
+
" #",
|
|
525
|
+
" # A 409 here is EXPECTED for a default-branch base or a drifted head:",
|
|
526
|
+
" # Bridge's completion path re-applies both guards above the failure",
|
|
527
|
+
" # branch and refuses. It is never retried.",
|
|
528
|
+
" # ------------------------------------------------------------------",
|
|
529
|
+
" - name: Report a denial to Bridge",
|
|
530
|
+
" if: failure()",
|
|
531
|
+
" shell: bash",
|
|
532
|
+
" run: |",
|
|
533
|
+
" set -uo pipefail",
|
|
534
|
+
" set +x",
|
|
535
|
+
' if [ -z "${PR_NUMBER:-}" ] || [ -z "${EXPECTED_HEAD_SHA:-}" ]; then',
|
|
536
|
+
' echo "::warning::Not enough resolved identity to report this denial; Bridge reconciles it when the lease expires."',
|
|
537
|
+
" exit 0",
|
|
538
|
+
" fi",
|
|
539
|
+
' if [ "${REFUSAL_REASON:-}" = "completion_unreported" ]; then',
|
|
540
|
+
' echo "::warning::The merge landed; no failure report is appropriate."',
|
|
541
|
+
" exit 0",
|
|
542
|
+
" fi",
|
|
543
|
+
" umask 077",
|
|
544
|
+
' curlrc="${RUNNER_TEMP}/bapi-denial.curlrc"',
|
|
545
|
+
' reqbody="${RUNNER_TEMP}/bapi-denial.json"',
|
|
546
|
+
" trap 'rm -f \"$curlrc\" \"$reqbody\"' EXIT",
|
|
547
|
+
" jq -rn 'env.BAPI_API_KEY | \"header = \\\"X-API-Key: \\(.)\\\"\"' > \"$curlrc\"",
|
|
548
|
+
" jq -n '{repo_name: (env.ACTION_KEY | split(\":\") | .[1] // \"\"), action_key: env.ACTION_KEY, expected_head_sha: env.EXPECTED_HEAD_SHA, result: \"failed\", reason: (env.REFUSAL_REASON // \"merge_outcome_ambiguous\"), lease_owner: env.LEASE_OWNER}' > \"$reqbody\"",
|
|
549
|
+
" code=\"$(curl --silent --show-error --no-progress-meter --retry 0 \\",
|
|
550
|
+
" --max-time 30 \\",
|
|
551
|
+
' --config "$curlrc" \\',
|
|
552
|
+
" --header 'Content-Type: application/json' \\",
|
|
553
|
+
" --request POST \\",
|
|
554
|
+
' --data-binary "@$reqbody" \\',
|
|
555
|
+
" --output /dev/null \\",
|
|
556
|
+
" --write-out '%{http_code}' \\",
|
|
557
|
+
' "${BAPI_BASE_URL%/}/vcs/pull-requests/$PR_NUMBER/merge/complete" || echo "000")"',
|
|
558
|
+
' case "$code" in',
|
|
559
|
+
" 200)",
|
|
560
|
+
' echo "Reported denial ${REFUSAL_REASON:-unknown} for pull request #$PR_NUMBER."',
|
|
561
|
+
" ;;",
|
|
562
|
+
" 409)",
|
|
563
|
+
' echo "::warning::Bridge refused the denial report as already settled (HTTP 409). This is expected for a default-branch base or a drifted head and is not retried."',
|
|
564
|
+
" ;;",
|
|
565
|
+
" *)",
|
|
566
|
+
' echo "::warning::The denial report was not accepted (HTTP $code); Bridge reconciles this attempt when the lease expires. Escalate to a repository administrator if it does not clear."',
|
|
567
|
+
" ;;",
|
|
568
|
+
" esac",
|
|
569
|
+
"",
|
|
570
|
+
];
|
|
571
|
+
return lines.join("\n");
|
|
572
|
+
}
|