ace-git 0.29.0 → 0.30.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/docs/usage.md +17 -0
- data/handbook/workflow-instructions/git/pr/create.wf.md +34 -0
- data/handbook/workflow-instructions/git/pr/update.wf.md +34 -0
- data/lib/ace/git/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/git/atoms/service_merge_evidence.rb +71 -0
- data/lib/ace/git/atoms/service_pr_evidence.rb +76 -0
- data/lib/ace/git/atoms/service_pr_input.rb +56 -0
- data/lib/ace/git/cli/commands/service_merge.rb +29 -0
- data/lib/ace/git/cli.rb +10 -1
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +26 -5
- data/lib/ace/git/organisms/service_merge.rb +214 -0
- data/lib/ace/git/providers/base.rb +6 -0
- data/lib/ace/git/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecccf931f808308fde6ab00654e43fdcbe7b90e0f0c929ade43a25a427302756
|
|
4
|
+
data.tar.gz: 03af540433b453863a3d399a67565724c68288c0a4a2b283462f613293a4a6a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c999f3855347dda4bfac0ce490ec94666f1e9e2cf00b2907d0ad72fa5c083e00495e0f4296036770d106bb1decddca02a37b495e71d87552344899a4321cba08
|
|
7
|
+
data.tar.gz: 0decf0ad84220579f16b3606d9c6ee3d6193e0a4dfbee22474cd4ee8008b5a75c6fc4bfc9f77bb8d27ae14fbdf65a3c95c957644ac318e08caffe03f7d0e768c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.30.0] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Execute protected draft create/update, ready and merge through named forge providers, binding exact repository provenance, candidate SHA, input and current campaign acceptance.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Resolve receiver provider identity from the authenticated PR URL and preserve uncertain effects without replay.
|
|
19
|
+
|
|
20
|
+
- Verify protected request, status and delivery refuse actual authority endpoint loss without replacing the original claim or dispatching another effect.
|
|
21
|
+
|
|
22
|
+
- Restore process-wide provider registrations and loader state after registry test fixtures, preserving real adapter tests across randomized single-batch execution.
|
|
23
|
+
|
|
24
|
+
- Resolve receiver-side merge provider identity from the authenticated PR URL when the prepared candidate has no remote, refusing mismatched or changed pinned servers.
|
|
25
|
+
|
|
26
|
+
- Route protected delivery through original worker merge request, canonical status and exact result consumption while preserving ordinary delivery and unresolved protected PR policy.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Fixed receiver-side `service merge` validates the claimed candidate/input, uses the selected neutral provider once, and publishes bounded evidence only for a verified exact-head merged outcome.
|
|
32
|
+
|
|
10
33
|
## [0.29.0] - 2026-10-05
|
|
11
34
|
|
|
12
35
|
### Removed
|
data/docs/usage.md
CHANGED
|
@@ -289,6 +289,23 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
|
|
|
289
289
|
|
|
290
290
|
## Provider Packages
|
|
291
291
|
|
|
292
|
+
`ace-git service create`, `service update`, `service ready` and `service merge`
|
|
293
|
+
are fixed protected receiver handlers. It accepts no
|
|
294
|
+
options; the receiver supplies its claimed JSON envelope on stdin and its
|
|
295
|
+
private materialized candidate directory as the working directory. The handler
|
|
296
|
+
checks the exact input digest, executor, candidate and forge provenance, calls
|
|
297
|
+
the selected neutral operation once, and emits the existing bounded evidence
|
|
298
|
+
response only after verifying the exact PR outcome (and merge commit for merge). An unverified or lost
|
|
299
|
+
outcome emits no success and never retries. This command does not authorize a
|
|
300
|
+
operation or replace the protected worker's canonical receipt acceptance.
|
|
301
|
+
Create/update use an exact `{target,delivery,title,body}` input and remain draft;
|
|
302
|
+
ready uses `{target,delivery}`, merge uses `{target,delivery,method}`. Create targets
|
|
303
|
+
the base repository; all other operations target the exact PR URL. The original
|
|
304
|
+
title remains part of the accepted input; the provider owns its draft encoding
|
|
305
|
+
(Forgejo's WIP prefix). Updating a draft preserves that encoding. Each handler
|
|
306
|
+
publishes the fixed `pr-result.txt` evidence only after its observed content,
|
|
307
|
+
identity, head and draft/merge outcome agree.
|
|
308
|
+
|
|
292
309
|
`ace-git` is forge-neutral. It defines the server registry (`Ace::Git::ServerRegistry`),
|
|
293
310
|
the provider contract (`Ace::Git::Providers::Base`), and normalized evidence types;
|
|
294
311
|
provider packages own all forge CLI behavior:
|
|
@@ -22,6 +22,40 @@ Use `forge_server` or `forge_default: true`, mutually exclusive. Neither means r
|
|
|
22
22
|
{"forge_server":"forge-lab","pr_provenance":{"mode":"canonical","head_repository_url":"https://forge.example/team/repo","head_ref":"feature","base_repository_url":"https://forge.example/team/repo","base_ref":"main"}}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
## Protected worker boundary
|
|
26
|
+
|
|
27
|
+
The steps below are ordinary managed delivery only. An installed protected
|
|
28
|
+
participant uses the configured receiver and the original canonical authority,
|
|
29
|
+
with no caller-local journal or direct-provider fallback.
|
|
30
|
+
|
|
31
|
+
The authenticated worker may create/update a draft before independent review.
|
|
32
|
+
Save a nonsecret input object with exactly `target`, `delivery`, `title`, `body`;
|
|
33
|
+
`delivery` is the normalized original forge/provenance mapping. `target` has
|
|
34
|
+
`resource` and nullable `artifact_digest`: the selected base repository URL for
|
|
35
|
+
create, the exact recorded PR URL for update. Title/body are bounded UTF-8 text.
|
|
36
|
+
Request the installed receiver with:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
ace-lab service request --project PROJECT --assignment ID --attempt ATTEMPT \
|
|
40
|
+
--mapping MAPPING --scope SCOPE --service SERVICE --operation OPERATION \
|
|
41
|
+
--candidate-head SHA --candidate-generation CANDIDATE-GENERATION \
|
|
42
|
+
--expected-generation AUTHORITY-GENERATION --authorization AUTHORIZATION \
|
|
43
|
+
--request-id REQUEST --input INPUT.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use `create` or `update` as OPERATION; each requires its own exact scoped grant.
|
|
47
|
+
The receiver runs the fixed `ace-git service create` or `service update` handler.
|
|
48
|
+
A claim acknowledges admission, not PR success. Retain its complete selection
|
|
49
|
+
and observe `ace-lab service status` as described in
|
|
50
|
+
`wfi://handbook/perform-delivery`. Lost replies stay uncertain; status does not
|
|
51
|
+
repeat an effect. Only canonical completion supplies the created PR URL.
|
|
52
|
+
|
|
53
|
+
For ready, use the same request with OPERATION `ready` and input containing only
|
|
54
|
+
`target` and `delivery`. Ready and merge require executed checks, accepted
|
|
55
|
+
independent review for the current revision and the matching authorization.
|
|
56
|
+
Local report/reference files cannot replace those canonical proofs. Draft
|
|
57
|
+
creation/update does not grant readiness, merge or publication.
|
|
58
|
+
|
|
25
59
|
## Steps
|
|
26
60
|
|
|
27
61
|
1. Read the task and actual committed diff; prepare a concrete title and description explaining behavior and executed validation. Save the description as a local file.
|
|
@@ -14,6 +14,40 @@ Update the recorded PR description for the current candidate and preserve provid
|
|
|
14
14
|
|
|
15
15
|
A managed assignment with a recorded PR and active delivery attempt. Reuse the original forge selection and complete fork/canonical provenance; changed configuration or identity is a conflict. A new committed candidate, including task-only changes, requires renewed tests and independent review.
|
|
16
16
|
|
|
17
|
+
## Protected worker boundary
|
|
18
|
+
|
|
19
|
+
The steps below are ordinary managed delivery only. An installed protected
|
|
20
|
+
participant uses the configured receiver and the original canonical authority,
|
|
21
|
+
with no caller-local journal or direct-provider fallback.
|
|
22
|
+
|
|
23
|
+
The authenticated worker may create/update a draft before independent review.
|
|
24
|
+
Save a nonsecret input object with exactly `target`, `delivery`, `title`, `body`;
|
|
25
|
+
`delivery` is the normalized original forge/provenance mapping. `target` has
|
|
26
|
+
`resource` and nullable `artifact_digest`: the selected base repository URL for
|
|
27
|
+
create, the exact recorded PR URL for update. Title/body are bounded UTF-8 text.
|
|
28
|
+
Request the installed receiver with:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
ace-lab service request --project PROJECT --assignment ID --attempt ATTEMPT \
|
|
32
|
+
--mapping MAPPING --scope SCOPE --service SERVICE --operation OPERATION \
|
|
33
|
+
--candidate-head SHA --candidate-generation CANDIDATE-GENERATION \
|
|
34
|
+
--expected-generation AUTHORITY-GENERATION --authorization AUTHORIZATION \
|
|
35
|
+
--request-id REQUEST --input INPUT.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use `create` or `update` as OPERATION; each requires its own exact scoped grant.
|
|
39
|
+
The receiver runs the fixed `ace-git service create` or `service update` handler.
|
|
40
|
+
A claim acknowledges admission, not PR success. Retain its complete selection
|
|
41
|
+
and observe `ace-lab service status` as described in
|
|
42
|
+
`wfi://handbook/perform-delivery`. Lost replies stay uncertain; status does not
|
|
43
|
+
repeat an effect. Only canonical completion supplies the created PR URL.
|
|
44
|
+
|
|
45
|
+
For ready, use the same request with OPERATION `ready` and input containing only
|
|
46
|
+
`target` and `delivery`. Ready and merge require executed checks, accepted
|
|
47
|
+
independent review for the current revision and the matching authorization.
|
|
48
|
+
Local report/reference files cannot replace those canonical proofs. Draft
|
|
49
|
+
creation/update does not grant readiness, merge or publication.
|
|
50
|
+
|
|
17
51
|
## Steps
|
|
18
52
|
|
|
19
53
|
1. Inspect the committed changes and accepted test/review evidence. Prepare the final description with the concrete behavior, executed test results, independent verdict, and actual limitations. Save it as a local file; do not put credentials or terminal dumps in the attempt journal.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "server_url"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Git
|
|
6
|
+
module Atoms
|
|
7
|
+
# Explicit non-secret remote delivery inputs; local recipes need not call this validator.
|
|
8
|
+
module DeliveryParameters
|
|
9
|
+
PROVENANCE_FIELDS = %w[mode head_repository_url head_ref base_repository_url base_ref].freeze
|
|
10
|
+
def self.validate(input)
|
|
11
|
+
raise ArgumentError, "Delivery parameters must be a mapping" unless input.is_a?(Hash)
|
|
12
|
+
unless (input.keys - %w[forge_server forge_default pr_provenance]).empty?
|
|
13
|
+
raise ArgumentError, "Unexpected delivery parameter fields"
|
|
14
|
+
end
|
|
15
|
+
default = input.fetch("forge_default", false)
|
|
16
|
+
raise ArgumentError, "forge_default must be boolean" unless [true, false].include?(default)
|
|
17
|
+
server = input["forge_server"]
|
|
18
|
+
if server && (!server.is_a?(String) || !server.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/))
|
|
19
|
+
raise ArgumentError, "Invalid forge_server"
|
|
20
|
+
end
|
|
21
|
+
raise ArgumentError, "forge_server and forge_default are mutually exclusive" if server && default
|
|
22
|
+
provenance = input["pr_provenance"]
|
|
23
|
+
unless provenance.is_a?(Hash) && provenance.keys.sort == PROVENANCE_FIELDS.sort &&
|
|
24
|
+
%w[fork canonical].include?(provenance["mode"])
|
|
25
|
+
raise ArgumentError, "Explicit complete fork or canonical PR provenance is required"
|
|
26
|
+
end
|
|
27
|
+
%w[head_repository_url base_repository_url].each { |key| validate_url!(provenance[key]) }
|
|
28
|
+
%w[head_ref base_ref].each do |key|
|
|
29
|
+
ref = provenance[key]
|
|
30
|
+
unless ref.is_a?(String) && ref.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_\/.\-]{0,255}\z/) &&
|
|
31
|
+
!ref.include?("..") && !ref.include?("//") && !ref.end_with?("/", ".", ".lock")
|
|
32
|
+
raise ArgumentError, "Invalid PR ref"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
if provenance["mode"] == "canonical" && !Ace::Git::Atoms::ServerUrl.match?(
|
|
36
|
+
provenance["head_repository_url"], provenance["base_repository_url"])
|
|
37
|
+
raise ArgumentError, "Canonical provenance requires matching head and base repositories"
|
|
38
|
+
end
|
|
39
|
+
{"forge_server" => server, "forge_default" => default,
|
|
40
|
+
"pr_provenance" => provenance.transform_values(&:dup)}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.validate_url!(url)
|
|
44
|
+
unless url.is_a?(String) && url.length <= 512 &&
|
|
45
|
+
(url.match?(%r{\Ahttps?://[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}) ||
|
|
46
|
+
url.match?(%r{\Agit@[a-zA-Z0-9.-]+:[a-zA-Z0-9_./-]+\z}) ||
|
|
47
|
+
url.match?(%r{\Assh://git@[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}))
|
|
48
|
+
raise ArgumentError, "PR repository URL must contain a repository and no credentials or query"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "delivery_parameters"
|
|
4
|
+
require_relative "pr_reference"
|
|
5
|
+
require_relative "../providers/evidence"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Atoms
|
|
10
|
+
# Parse executor-owned evidence; authority supplies the original binding
|
|
11
|
+
# and separately checks the reconstructed input's canonical digest.
|
|
12
|
+
module ServiceMergeEvidence
|
|
13
|
+
MAX_BYTES = 64 * 1024
|
|
14
|
+
def self.validate(bytes, request_id:, input_digest:, target:, head:)
|
|
15
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_BYTES) &&
|
|
16
|
+
bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
|
|
17
|
+
raise ArgumentError, "merge evidence is invalid or oversized"
|
|
18
|
+
end
|
|
19
|
+
marker, payload = bytes.split("\n", 2)
|
|
20
|
+
unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
|
|
21
|
+
raise ArgumentError, "merge evidence original marker differs"
|
|
22
|
+
end
|
|
23
|
+
value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
|
|
24
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false)
|
|
25
|
+
closed!(value, %w[server delivery method candidate_head receipt])
|
|
26
|
+
delivery = DeliveryParameters.validate(value.fetch("delivery"))
|
|
27
|
+
unless value["delivery"] == delivery && value["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] &&
|
|
28
|
+
%w[squash merge rebase].include?(value["method"]) && value["candidate_head"] == head
|
|
29
|
+
raise ArgumentError, "merge evidence selection/method/head differs"
|
|
30
|
+
end
|
|
31
|
+
server, receipt = value.values_at("server", "receipt")
|
|
32
|
+
closed!(server, %w[name provider url])
|
|
33
|
+
closed!(receipt, %w[server_name operation pull_request idempotency])
|
|
34
|
+
unless server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
|
|
35
|
+
server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
|
|
36
|
+
receipt["server_name"] == server["name"] && receipt["operation"] == "merge"
|
|
37
|
+
raise ArgumentError, "merge evidence provider receipt differs"
|
|
38
|
+
end
|
|
39
|
+
DeliveryParameters.validate_url!(server["url"])
|
|
40
|
+
pr = receipt.fetch("pull_request")
|
|
41
|
+
closed!(pr, ProviderPullRequest.members.map(&:to_s))
|
|
42
|
+
provenance = delivery.fetch("pr_provenance")
|
|
43
|
+
reference = target.is_a?(Hash) && PrReference.parse(target["resource"])
|
|
44
|
+
unless reference && reference.repository_url && pr["number"].is_a?(Integer) && pr["number"].positive? &&
|
|
45
|
+
pr["number"] == reference.number && pr["url"] == target["resource"] && pr["server_name"] == server["name"] &&
|
|
46
|
+
pr["head_sha"] == head && head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) &&
|
|
47
|
+
pr["state"] == "merged" && [true, false].include?(pr["draft"]) &&
|
|
48
|
+
pr.except("number", "draft").values.all? { |value| value.nil? || value.is_a?(String) } &&
|
|
49
|
+
pr["merge_commit_sha"].is_a?(String) && pr["merge_commit_sha"].match?(/\A[0-9a-f]{40}\z/) &&
|
|
50
|
+
pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
|
|
51
|
+
ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
|
|
52
|
+
ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
|
|
53
|
+
ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
|
|
54
|
+
ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
|
|
55
|
+
(!delivery["forge_server"] || delivery["forge_server"] == server["name"])
|
|
56
|
+
raise ArgumentError, "merge evidence PR identity or outcome differs"
|
|
57
|
+
end
|
|
58
|
+
{input: {"target" => target, "delivery" => delivery, "method" => value.fetch("method")},
|
|
59
|
+
pr: pr, server: server}
|
|
60
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
61
|
+
raise ArgumentError, "merge evidence is malformed"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.closed!(value, fields)
|
|
65
|
+
raise ArgumentError, "merge evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
66
|
+
end
|
|
67
|
+
private_class_method :closed!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "service_pr_input"
|
|
4
|
+
require_relative "../providers"
|
|
5
|
+
require_relative "../server_registry"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
module Atoms
|
|
10
|
+
module ServicePrEvidence
|
|
11
|
+
def self.validate(bytes, operation:, request_id:, input_digest:, target:, head:)
|
|
12
|
+
unless ServicePrInput::DRAFT_OPERATIONS.include?(operation) || operation == "ready"
|
|
13
|
+
raise ArgumentError, "unsupported PR evidence operation"
|
|
14
|
+
end
|
|
15
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 64 * 1024) &&
|
|
16
|
+
bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
|
|
17
|
+
raise ArgumentError, "PR evidence is invalid or oversized"
|
|
18
|
+
end
|
|
19
|
+
marker, payload = bytes.split("\n", 2)
|
|
20
|
+
unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
|
|
21
|
+
raise ArgumentError, "PR evidence original marker differs"
|
|
22
|
+
end
|
|
23
|
+
value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
|
|
24
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false)
|
|
25
|
+
closed!(value, %w[server operation input candidate_head receipt])
|
|
26
|
+
input = ServicePrInput.validate(value.fetch("input"), operation: operation)
|
|
27
|
+
unless value["operation"] == operation && value["candidate_head"] == head && input["target"] == target
|
|
28
|
+
raise ArgumentError, "PR evidence operation/input/head differs"
|
|
29
|
+
end
|
|
30
|
+
server, receipt = value.values_at("server", "receipt")
|
|
31
|
+
closed!(server, %w[name provider url])
|
|
32
|
+
closed!(receipt, %w[server_name operation pull_request idempotency])
|
|
33
|
+
pr = receipt.fetch("pull_request")
|
|
34
|
+
closed!(pr, ProviderPullRequest.members.map(&:to_s))
|
|
35
|
+
delivery = input.fetch("delivery")
|
|
36
|
+
provenance = delivery.fetch("pr_provenance")
|
|
37
|
+
reference = PrReference.parse(pr["url"])
|
|
38
|
+
unless server["name"].is_a?(String) && server["provider"].is_a?(String) && server["url"].is_a?(String)
|
|
39
|
+
raise ArgumentError, "PR evidence provider identity is malformed"
|
|
40
|
+
end
|
|
41
|
+
selected = ResolvedServer.new(name: server["name"], provider: server["provider"], url: server["url"])
|
|
42
|
+
expected_title = Providers.for(selected).class.pull_request_title(title: input["title"], draft: true) if ServicePrInput::DRAFT_OPERATIONS.include?(operation)
|
|
43
|
+
unless reference && reference.repository_url && pr["number"] == reference.number &&
|
|
44
|
+
pr["number"].is_a?(Integer) && pr["number"].positive? &&
|
|
45
|
+
server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
|
|
46
|
+
server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
|
|
47
|
+
receipt["server_name"] == server["name"] && receipt["operation"] == operation &&
|
|
48
|
+
pr["server_name"] == server["name"] && pr["head_sha"] == head &&
|
|
49
|
+
head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) && pr["state"] == "open" &&
|
|
50
|
+
pr["draft"] == ServicePrInput::DRAFT_OPERATIONS.include?(operation) &&
|
|
51
|
+
pr.except("number", "draft").values.all? { |item| item.nil? || item.is_a?(String) } &&
|
|
52
|
+
(operation == "create" ? %w[created existing].include?(receipt["idempotency"]) : receipt["idempotency"].nil?) &&
|
|
53
|
+
pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
|
|
54
|
+
ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
|
|
55
|
+
ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
|
|
56
|
+
ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
|
|
57
|
+
ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
|
|
58
|
+
(!delivery["forge_server"] || delivery["forge_server"] == server["name"]) &&
|
|
59
|
+
(operation == "create" || pr["url"] == target["resource"]) &&
|
|
60
|
+
(operation == "ready" || pr["title"] == expected_title && pr["body"] == input["body"])
|
|
61
|
+
raise ArgumentError, "PR evidence selected identity or outcome differs"
|
|
62
|
+
end
|
|
63
|
+
DeliveryParameters.validate_url!(server["url"])
|
|
64
|
+
{input: input, pr: pr, server: server}
|
|
65
|
+
rescue JSON::ParserError, KeyError, TypeError, Ace::Git::Error
|
|
66
|
+
raise ArgumentError, "PR evidence is malformed"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.closed!(value, fields)
|
|
70
|
+
raise ArgumentError, "PR evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
71
|
+
end
|
|
72
|
+
private_class_method :closed!
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "delivery_parameters"
|
|
3
|
+
require_relative "pr_reference"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Atoms
|
|
8
|
+
# Fixed receiver input vocabulary; draft is source policy, not a flag.
|
|
9
|
+
module ServicePrInput
|
|
10
|
+
OPERATIONS = %w[create update ready merge].freeze
|
|
11
|
+
DRAFT_OPERATIONS = %w[create update].freeze
|
|
12
|
+
|
|
13
|
+
def self.validate(input, operation:)
|
|
14
|
+
fields = case operation
|
|
15
|
+
when "create", "update" then %w[target delivery title body]
|
|
16
|
+
when "ready" then %w[target delivery]
|
|
17
|
+
when "merge" then %w[target delivery method]
|
|
18
|
+
else raise ArgumentError, "unsupported protected PR operation"
|
|
19
|
+
end
|
|
20
|
+
unless input.is_a?(Hash) && input.keys.sort == fields.sort &&
|
|
21
|
+
input["target"].is_a?(Hash) && input["target"].keys.sort == %w[artifact_digest resource]
|
|
22
|
+
raise ArgumentError, "protected PR input fields differ"
|
|
23
|
+
end
|
|
24
|
+
parameters = DeliveryParameters.validate(input.fetch("delivery"))
|
|
25
|
+
unless input["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] && input["delivery"] == parameters
|
|
26
|
+
raise ArgumentError, "protected PR selection must be normalized"
|
|
27
|
+
end
|
|
28
|
+
resource = input.fetch("target").fetch("resource")
|
|
29
|
+
if operation == "create"
|
|
30
|
+
DeliveryParameters.validate_url!(resource)
|
|
31
|
+
unless ServerUrl.match?(resource, parameters.fetch("pr_provenance").fetch("base_repository_url"))
|
|
32
|
+
raise ArgumentError, "draft create target differs from base repository"
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
reference = resource.is_a?(String) && PrReference.parse(resource)
|
|
36
|
+
raise ArgumentError, "protected PR target requires an exact URL" unless reference && reference.repository_url
|
|
37
|
+
end
|
|
38
|
+
digest = input.fetch("target").fetch("artifact_digest")
|
|
39
|
+
unless digest.nil? || digest.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(digest)
|
|
40
|
+
raise ArgumentError, "protected PR target artifact digest differs"
|
|
41
|
+
end
|
|
42
|
+
if DRAFT_OPERATIONS.include?(operation)
|
|
43
|
+
unless input["title"].is_a?(String) && input["title"].bytesize.between?(1, 1024) &&
|
|
44
|
+
input["body"].is_a?(String) && input["body"].bytesize <= 32 * 1024 &&
|
|
45
|
+
[input["title"], input["body"]].all? { |text| text.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !text.include?("\0") }
|
|
46
|
+
raise ArgumentError, "draft PR requires bounded UTF-8 title/body"
|
|
47
|
+
end
|
|
48
|
+
elsif operation == "merge" && !%w[squash merge rebase].include?(input["method"])
|
|
49
|
+
raise ArgumentError, "protected merge requires an exact method"
|
|
50
|
+
end
|
|
51
|
+
input
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../../organisms/service_merge"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Git
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class ServiceMerge < Ace::Support::Cli::Command
|
|
9
|
+
desc "Execute a fixed receiver-owned exact-head PR envelope"
|
|
10
|
+
|
|
11
|
+
def initialize(input: nil, producer: nil, root: nil, operation: "merge")
|
|
12
|
+
@input, @producer, @root, @operation = input, producer, root, operation
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(**options)
|
|
16
|
+
raise ArgumentError, "fixed PR entry accepts no options" unless options.empty?
|
|
17
|
+
bytes = (@input || $stdin).read(Organisms::ServiceMerge::MAX_INPUT + 1)
|
|
18
|
+
result = (@producer || Organisms::ServiceMerge.new(operation: @operation)).call(bytes: bytes, root: @root || Dir.pwd)
|
|
19
|
+
puts JSON.generate(result)
|
|
20
|
+
rescue Ace::Git::Error, ArgumentError, SystemCallError, IOError => error
|
|
21
|
+
# No stdout receipt on an unverified/uncertain effect. The existing
|
|
22
|
+
# receiver retains its original dispatch instead of retrying it.
|
|
23
|
+
raise Ace::Support::Cli::Error, error.message
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/ace/git/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "cli/commands/diff"
|
|
|
7
7
|
require_relative "cli/commands/status"
|
|
8
8
|
require_relative "cli/commands/branch"
|
|
9
9
|
require_relative "cli/commands/pr"
|
|
10
|
+
require_relative "cli/commands/service_merge"
|
|
10
11
|
|
|
11
12
|
module Ace
|
|
12
13
|
module Git
|
|
@@ -52,7 +53,11 @@ module Ace
|
|
|
52
53
|
["diff", "Show filtered git diff output"],
|
|
53
54
|
["status", "Show repository status and PR context"],
|
|
54
55
|
["branch", "Show current branch information"],
|
|
55
|
-
["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"]
|
|
56
|
+
["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"],
|
|
57
|
+
["service merge", "Fixed receiver-owned exact-head merge envelope"],
|
|
58
|
+
["service create", "Fixed receiver-owned draft PR creation"],
|
|
59
|
+
["service update", "Fixed receiver-owned draft PR update"],
|
|
60
|
+
["service ready", "Fixed receiver-owned reviewed PR readiness"]
|
|
56
61
|
].freeze
|
|
57
62
|
|
|
58
63
|
HELP_EXAMPLES = [
|
|
@@ -70,6 +75,10 @@ module Ace
|
|
|
70
75
|
register "pr update", Commands::Pr::Update.new
|
|
71
76
|
register "pr ready", Commands::Pr::Ready.new
|
|
72
77
|
register "pr merge", Commands::Pr::Merge.new
|
|
78
|
+
register "service merge", Commands::ServiceMerge.new
|
|
79
|
+
%w[create update ready].each do |operation|
|
|
80
|
+
register "service #{operation}", Commands::ServiceMerge.new(operation: operation)
|
|
81
|
+
end
|
|
73
82
|
|
|
74
83
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
75
84
|
gem_name: "ace-git",
|
|
@@ -196,6 +196,11 @@ module Ace
|
|
|
196
196
|
)
|
|
197
197
|
end
|
|
198
198
|
|
|
199
|
+
# Pure provider-owned encoding; selection is pinned by the caller.
|
|
200
|
+
def draft_title(title)
|
|
201
|
+
provider_for(resolve_selected_server).class.pull_request_title(title: title, draft: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
199
204
|
# Update title/body after exact-head verification.
|
|
200
205
|
#
|
|
201
206
|
# @return [ProviderMutationReceipt]
|
|
@@ -237,8 +242,23 @@ module Ace
|
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
# Freeze the selected provider identity for an assignment attempt.
|
|
240
|
-
def resolved_identity
|
|
241
|
-
resolve_selected_server
|
|
245
|
+
def resolved_identity(identifier = nil)
|
|
246
|
+
selected = identifier.nil? ? resolve_selected_server : resolve_server_for(parse_identifier(identifier))
|
|
247
|
+
if @pinned_server && selected != @pinned_server
|
|
248
|
+
raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
|
|
249
|
+
end
|
|
250
|
+
selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Protected create has no PR identifier or materialized origin remote.
|
|
254
|
+
# Resolve the retained base repository through the same exact registry.
|
|
255
|
+
def resolved_repository_identity(repository_url)
|
|
256
|
+
Atoms::DeliveryParameters.validate_url!(repository_url)
|
|
257
|
+
selected = resolve_matching_server(ServerRegistry.matching_servers(repository_url), repository_url)
|
|
258
|
+
if @pinned_server && selected != @pinned_server
|
|
259
|
+
raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
|
|
260
|
+
end
|
|
261
|
+
selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
242
262
|
end
|
|
243
263
|
|
|
244
264
|
# Read-only reconciliation of a possibly completed create. Absence
|
|
@@ -291,10 +311,13 @@ module Ace
|
|
|
291
311
|
ServerRegistry.servers_for_owner_repo(reference.owner_repo)
|
|
292
312
|
end
|
|
293
313
|
|
|
314
|
+
resolve_matching_server(candidates, reference.repository_url || reference.owner_repo)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def resolve_matching_server(candidates, identity)
|
|
294
318
|
if @selection[:server_name] || @selection[:use_default]
|
|
295
319
|
selected = resolve_selected_server
|
|
296
320
|
unless candidates.any? { |candidate| candidate.name == selected.name }
|
|
297
|
-
identity = reference.repository_url || reference.owner_repo
|
|
298
321
|
raise ProviderIdentityMismatchError,
|
|
299
322
|
"PR identifier (#{identity}) does not match the selected server '#{selected.name}'"
|
|
300
323
|
end
|
|
@@ -303,12 +326,10 @@ module Ace
|
|
|
303
326
|
elsif candidates.size == 1
|
|
304
327
|
candidates.first
|
|
305
328
|
elsif candidates.empty?
|
|
306
|
-
identity = reference.repository_url || reference.owner_repo
|
|
307
329
|
raise AmbiguousRemoteError,
|
|
308
330
|
"PR repository '#{identity}' matches no configured server (configured: " \
|
|
309
331
|
"#{ServerRegistry.servers.map(&:name).join(", ")})"
|
|
310
332
|
else
|
|
311
|
-
identity = reference.repository_url || reference.owner_repo
|
|
312
333
|
raise AmbiguousRemoteError,
|
|
313
334
|
"PR repository '#{identity}' matches multiple configured servers " \
|
|
314
335
|
"(#{candidates.map(&:name).join(", ")}); select one explicitly"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "pull_request_lifecycle"
|
|
5
|
+
require_relative "../atoms/delivery_parameters"
|
|
6
|
+
require_relative "../atoms/service_pr_input"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Git
|
|
10
|
+
module Organisms
|
|
11
|
+
# A fixed receiver-side operation, never a grant or a worker fallback.
|
|
12
|
+
class ServiceMerge
|
|
13
|
+
MAX_INPUT = 128 * 1024
|
|
14
|
+
MAX_SERVICE_INPUT = 64 * 1024
|
|
15
|
+
MAX_ARTIFACT = 64 * 1024
|
|
16
|
+
ARTIFACT = "pr-result.txt"
|
|
17
|
+
REQUEST = %w[request_id assignment_id attempt_id project_id operation input_digest target candidate_head
|
|
18
|
+
executor_uid transport authorization service_id caller_uid].freeze
|
|
19
|
+
EXECUTION = %w[executor_uid authority_id claim_binding candidate_generation head staging_id].freeze
|
|
20
|
+
TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
21
|
+
SHA = /\A[0-9a-f]{40}\z/
|
|
22
|
+
DIGEST = /\A[0-9a-f]{64}\z/
|
|
23
|
+
|
|
24
|
+
def initialize(operation: "merge", lifecycle_factory: nil, uid: -> { Process.uid }, euid: -> { Process.euid })
|
|
25
|
+
raise ArgumentError, "unsupported fixed PR operation" unless Atoms::ServicePrInput::OPERATIONS.include?(operation)
|
|
26
|
+
@operation = operation
|
|
27
|
+
@factory = lifecycle_factory || ->(**selection) { PullRequestLifecycle.new(**selection) }
|
|
28
|
+
@uid, @euid = uid, euid
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(bytes:, root:)
|
|
32
|
+
envelope = parse!(bytes)
|
|
33
|
+
request, execution, input = envelope.values_at("request", "execution", "input")
|
|
34
|
+
parameters = Atoms::DeliveryParameters.validate(input.fetch("delivery"))
|
|
35
|
+
Atoms::ServicePrInput.validate(input, operation: @operation)
|
|
36
|
+
directory, held = private_root!(root)
|
|
37
|
+
begin
|
|
38
|
+
lifecycle = @factory.call(server_name: parameters["forge_server"], use_default: parameters.fetch("forge_default"), repo_root: directory)
|
|
39
|
+
server = if @operation == "create"
|
|
40
|
+
lifecycle.resolved_repository_identity(request.fetch("target").fetch("resource"))
|
|
41
|
+
else
|
|
42
|
+
lifecycle.resolved_identity(request.fetch("target").fetch("resource"))
|
|
43
|
+
end
|
|
44
|
+
provenance = parameters.fetch("pr_provenance")
|
|
45
|
+
unless Atoms::ServerUrl.match?(server.fetch("url"), provenance.fetch("base_repository_url"))
|
|
46
|
+
raise ProviderIdentityMismatchError, "merge forge differs from accepted provenance"
|
|
47
|
+
end
|
|
48
|
+
pinned = ResolvedServer.new(name: server.fetch("name"), provider: server.fetch("provider").to_sym, url: server.fetch("url"))
|
|
49
|
+
lifecycle = @factory.call(server_name: pinned.name, use_default: false, repo_root: directory, resolved_server: pinned)
|
|
50
|
+
resource, head = request.fetch("target").fetch("resource"), execution.fetch("head")
|
|
51
|
+
if @operation != "create"
|
|
52
|
+
before = lifecycle.show(resource)
|
|
53
|
+
verify_pr!(before, resource, head, server, provenance)
|
|
54
|
+
if %w[update ready].include?(@operation) && before.state.to_s != "open"
|
|
55
|
+
raise ProviderExpectedHeadConflictError, "protected PR edit requires an open PR"
|
|
56
|
+
end
|
|
57
|
+
if @operation == "update" && !before.draft
|
|
58
|
+
raise ProviderExpectedHeadConflictError, "protected update requires a draft PR"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
title = lifecycle.draft_title(input.fetch("title")) if Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
|
|
62
|
+
receipt = case @operation
|
|
63
|
+
when "create"
|
|
64
|
+
lifecycle.create(head_ref: provenance.fetch("head_ref"), base_ref: provenance.fetch("base_ref"),
|
|
65
|
+
head_repository_url: provenance.fetch("head_repository_url"), expected_head: head,
|
|
66
|
+
title: title, body: input.fetch("body"), draft: true)
|
|
67
|
+
when "update"
|
|
68
|
+
lifecycle.update(resource, expected_head: head, title: title, body: input.fetch("body"))
|
|
69
|
+
when "ready" then lifecycle.ready(resource, expected_head: head)
|
|
70
|
+
when "merge" then lifecycle.merge(resource, expected_head: head, method: input.fetch("method"))
|
|
71
|
+
end
|
|
72
|
+
unless receipt.is_a?(ProviderMutationReceipt) && receipt.server_name == pinned.name && receipt.operation.to_s == @operation
|
|
73
|
+
raise ProviderUnknownOutcomeError, "PR response does not identify the selected operation"
|
|
74
|
+
end
|
|
75
|
+
after = receipt.pull_request
|
|
76
|
+
raise ProviderUnknownOutcomeError, "PR response lacks its exact result" unless after.is_a?(ProviderPullRequest)
|
|
77
|
+
verify_pr!(after, @operation == "create" ? after.url : resource, head, server, provenance)
|
|
78
|
+
if @operation == "merge"
|
|
79
|
+
unless after.state.to_s == "merged" && after.merge_commit_sha.is_a?(String) && after.merge_commit_sha.match?(SHA)
|
|
80
|
+
raise ProviderUnknownOutcomeError, "merge outcome is not verified"
|
|
81
|
+
end
|
|
82
|
+
payload = {"server" => server, "delivery" => parameters, "method" => input.fetch("method"),
|
|
83
|
+
"candidate_head" => head, "receipt" => receipt.to_h}
|
|
84
|
+
else
|
|
85
|
+
expected_draft = Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
|
|
86
|
+
unless after.state.to_s == "open" && after.draft == expected_draft &&
|
|
87
|
+
(!expected_draft || after.title == title && after.body == input.fetch("body"))
|
|
88
|
+
raise ProviderUnknownOutcomeError, "PR draft/content outcome is not verified"
|
|
89
|
+
end
|
|
90
|
+
payload = {"server" => server, "operation" => @operation, "input" => input,
|
|
91
|
+
"candidate_head" => head, "receipt" => receipt.to_h}
|
|
92
|
+
end
|
|
93
|
+
artifact = "ace-service-attestation request:#{request.fetch('request_id')} input:#{request.fetch('input_digest')} outcome:succeeded\n" + JSON.generate(payload) + "\n"
|
|
94
|
+
digest = publish!(directory, held, artifact)
|
|
95
|
+
{"request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
|
|
96
|
+
"outcome" => "succeeded", "evidence" => [{"ref" => ARTIFACT, "sha256" => digest}]}
|
|
97
|
+
ensure
|
|
98
|
+
held.close
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse!(bytes)
|
|
105
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_INPUT) && bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
|
|
106
|
+
raise ArgumentError, "merge envelope is invalid or oversized"
|
|
107
|
+
end
|
|
108
|
+
envelope = JSON.parse(bytes, max_nesting: 16, create_additions: false,
|
|
109
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false)
|
|
110
|
+
closed!(envelope, %w[version request input execution])
|
|
111
|
+
raise ArgumentError, "merge envelope version differs" unless envelope["version"].is_a?(Integer) && envelope["version"] == 1
|
|
112
|
+
request, execution, input = envelope.values_at("request", "execution", "input")
|
|
113
|
+
closed!(request, REQUEST)
|
|
114
|
+
closed!(execution, EXECUTION)
|
|
115
|
+
Atoms::ServicePrInput.validate(input, operation: @operation)
|
|
116
|
+
raise ArgumentError, "merge service input exceeds receiver bound" if JSON.generate(input).bytesize > MAX_SERVICE_INPUT
|
|
117
|
+
closed!(input["target"], %w[resource artifact_digest])
|
|
118
|
+
closed!(request["target"], %w[resource artifact_digest])
|
|
119
|
+
%w[request_id assignment_id attempt_id project_id authorization service_id].each do |key|
|
|
120
|
+
raise ArgumentError, "merge request identifier differs" unless request[key].is_a?(String) && request[key].match?(TOKEN)
|
|
121
|
+
end
|
|
122
|
+
%w[authority_id staging_id].each do |key|
|
|
123
|
+
raise ArgumentError, "merge execution identifier differs" unless execution[key].is_a?(String) && execution[key].match?(TOKEN)
|
|
124
|
+
end
|
|
125
|
+
unless request["operation"] == @operation && request["transport"] == "unix" &&
|
|
126
|
+
%w[executor_uid caller_uid].all? { |key| request[key].is_a?(Integer) && request[key].positive? } &&
|
|
127
|
+
request["executor_uid"] != request["caller_uid"] && request["executor_uid"] == execution["executor_uid"] &&
|
|
128
|
+
execution["executor_uid"].is_a?(Integer) &&
|
|
129
|
+
request["executor_uid"] == @uid.call && request["executor_uid"] == @euid.call &&
|
|
130
|
+
execution["candidate_generation"].is_a?(Integer) && execution["candidate_generation"].positive? &&
|
|
131
|
+
execution["head"].is_a?(String) && execution["head"].match?(SHA) && request["candidate_head"] == execution["head"] &&
|
|
132
|
+
execution["claim_binding"].is_a?(String) && execution["claim_binding"].match?(DIGEST) &&
|
|
133
|
+
request["input_digest"].is_a?(String) && request["input_digest"].match?(DIGEST) &&
|
|
134
|
+
request["input_digest"] == Digest::SHA256.hexdigest(JSON.generate(canonical(input))) && request["target"] == input["target"]
|
|
135
|
+
raise ArgumentError, "merge original claim/input/executor join differs"
|
|
136
|
+
end
|
|
137
|
+
target = input.fetch("target")
|
|
138
|
+
reference = target["resource"].is_a?(String) && Atoms::PrReference.parse(target["resource"])
|
|
139
|
+
unless (@operation == "create" || reference && reference.repository_url) && target["resource"].is_a?(String) && target["resource"].bytesize.between?(1, 256) &&
|
|
140
|
+
(target["artifact_digest"].nil? || target["artifact_digest"].is_a?(String) && target["artifact_digest"].match?(DIGEST))
|
|
141
|
+
raise ArgumentError, "merge target requires an exact PR URL"
|
|
142
|
+
end
|
|
143
|
+
envelope
|
|
144
|
+
rescue JSON::ParserError, JSON::NestingError, KeyError
|
|
145
|
+
raise ArgumentError, "merge envelope is malformed"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def closed!(value, fields)
|
|
149
|
+
raise ArgumentError, "merge fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def canonical(value)
|
|
153
|
+
case value
|
|
154
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
|
|
155
|
+
when Array then value.map { |item| canonical(item) }
|
|
156
|
+
else value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def verify_pr!(pr, resource, head, server, provenance)
|
|
161
|
+
reference = Atoms::PrReference.parse(resource)
|
|
162
|
+
unless pr.is_a?(ProviderPullRequest) && reference && reference.repository_url && pr.url == resource && pr.number == reference.number &&
|
|
163
|
+
pr.server_name == server.fetch("name") && pr.head_sha == head &&
|
|
164
|
+
pr.head_ref == provenance.fetch("head_ref") && pr.base_ref == provenance.fetch("base_ref") &&
|
|
165
|
+
Atoms::ServerUrl.match?(reference.repository_url, provenance.fetch("base_repository_url")) &&
|
|
166
|
+
Atoms::ServerUrl.match?(pr.head_repository_url, provenance.fetch("head_repository_url")) &&
|
|
167
|
+
Atoms::ServerUrl.match?(pr.base_repository_url, provenance.fetch("base_repository_url"))
|
|
168
|
+
raise ProviderExpectedHeadConflictError, "merge PR identity/head/provenance differs"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def private_root!(root)
|
|
173
|
+
raise ArgumentError, "merge staging root must be absolute" unless root.is_a?(String) && root.start_with?("/")
|
|
174
|
+
held = File.open(root, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
175
|
+
stat = held.stat
|
|
176
|
+
unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
|
|
177
|
+
[stat.dev, stat.ino] == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
178
|
+
raise ArgumentError, "merge staging root is not private"
|
|
179
|
+
end
|
|
180
|
+
[root, held]
|
|
181
|
+
rescue StandardError
|
|
182
|
+
held&.close
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def publish!(root, held, bytes)
|
|
187
|
+
raise ProviderUnknownOutcomeError, "merge result exceeds evidence bound" unless bytes.bytesize <= MAX_ARTIFACT
|
|
188
|
+
stat = held.stat
|
|
189
|
+
identity = [stat.dev, stat.ino]
|
|
190
|
+
unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
|
|
191
|
+
identity == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
192
|
+
raise ProviderUnknownOutcomeError, "merge staging root changed"
|
|
193
|
+
end
|
|
194
|
+
File.open(File.join(root, ARTIFACT), File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600) do |file|
|
|
195
|
+
file.write(bytes); file.flush; file.fsync; file.chmod(0o400)
|
|
196
|
+
before = file.stat
|
|
197
|
+
file.rewind
|
|
198
|
+
captured = file.read(MAX_ARTIFACT + 1)
|
|
199
|
+
after = file.stat
|
|
200
|
+
unless before.file? && before.uid == @uid.call && captured == bytes &&
|
|
201
|
+
[before.dev, before.ino, before.size, before.mtime, before.ctime] == [after.dev, after.ino, after.size, after.mtime, after.ctime]
|
|
202
|
+
raise ProviderUnknownOutcomeError, "merge held evidence changed"
|
|
203
|
+
end
|
|
204
|
+
held.fsync
|
|
205
|
+
raise ProviderUnknownOutcomeError, "merge staging root changed" unless identity == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
206
|
+
Digest::SHA256.hexdigest(captured)
|
|
207
|
+
end
|
|
208
|
+
rescue SystemCallError, IOError
|
|
209
|
+
raise ProviderUnknownOutcomeError, "merge evidence publication unavailable"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -17,6 +17,12 @@ module Ace
|
|
|
17
17
|
# keyword arguments (args:, timeout:, env:) and returning a Hash with
|
|
18
18
|
# :stdout, :stderr, :exit_code
|
|
19
19
|
class Base
|
|
20
|
+
# Pure transport title for the requested draft state. Providers own
|
|
21
|
+
# server-specific draft encodings; callers retain their original title.
|
|
22
|
+
def self.pull_request_title(title:, draft:)
|
|
23
|
+
title
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
attr_reader :server, :timeout
|
|
21
27
|
|
|
22
28
|
# @param server [ResolvedServer] resolved server identity
|
data/lib/ace/git/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|
|
@@ -141,6 +141,7 @@ files:
|
|
|
141
141
|
- lib/ace/git.rb
|
|
142
142
|
- lib/ace/git/atoms/command_executor.rb
|
|
143
143
|
- lib/ace/git/atoms/date_resolver.rb
|
|
144
|
+
- lib/ace/git/atoms/delivery_parameters.rb
|
|
144
145
|
- lib/ace/git/atoms/diff_numstat_parser.rb
|
|
145
146
|
- lib/ace/git/atoms/diff_parser.rb
|
|
146
147
|
- lib/ace/git/atoms/file_grouper.rb
|
|
@@ -154,6 +155,9 @@ files:
|
|
|
154
155
|
- lib/ace/git/atoms/repository_checker.rb
|
|
155
156
|
- lib/ace/git/atoms/repository_state_detector.rb
|
|
156
157
|
- lib/ace/git/atoms/server_url.rb
|
|
158
|
+
- lib/ace/git/atoms/service_merge_evidence.rb
|
|
159
|
+
- lib/ace/git/atoms/service_pr_evidence.rb
|
|
160
|
+
- lib/ace/git/atoms/service_pr_input.rb
|
|
157
161
|
- lib/ace/git/atoms/stale_lock_cleaner.rb
|
|
158
162
|
- lib/ace/git/atoms/status_formatter.rb
|
|
159
163
|
- lib/ace/git/atoms/task_pattern_extractor.rb
|
|
@@ -162,6 +166,7 @@ files:
|
|
|
162
166
|
- lib/ace/git/cli/commands/branch.rb
|
|
163
167
|
- lib/ace/git/cli/commands/diff.rb
|
|
164
168
|
- lib/ace/git/cli/commands/pr.rb
|
|
169
|
+
- lib/ace/git/cli/commands/service_merge.rb
|
|
165
170
|
- lib/ace/git/cli/commands/status.rb
|
|
166
171
|
- lib/ace/git/errors.rb
|
|
167
172
|
- lib/ace/git/models/diff_config.rb
|
|
@@ -177,6 +182,7 @@ files:
|
|
|
177
182
|
- lib/ace/git/organisms/issue_tracking.rb
|
|
178
183
|
- lib/ace/git/organisms/pull_request_lifecycle.rb
|
|
179
184
|
- lib/ace/git/organisms/repo_status_loader.rb
|
|
185
|
+
- lib/ace/git/organisms/service_merge.rb
|
|
180
186
|
- lib/ace/git/providers.rb
|
|
181
187
|
- lib/ace/git/providers/base.rb
|
|
182
188
|
- lib/ace/git/providers/evidence.rb
|