@agilno-tech/rivet 0.1.0-alpha.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.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +59 -0
- package/bin/cli.js +42 -0
- package/dist/agents/backend-django-agent/SKILL.md +35 -0
- package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
- package/dist/agents/boss-agent/SKILL.md +28 -0
- package/dist/agents/bounded-worker/SKILL.md +28 -0
- package/dist/agents/data-performance-agent/SKILL.md +32 -0
- package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
- package/dist/agents/devops-agent/SKILL.md +34 -0
- package/dist/agents/engineering-manager/SKILL.md +28 -0
- package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
- package/dist/agents/frontend-web-agent/SKILL.md +31 -0
- package/dist/agents/incident-response-agent/SKILL.md +42 -0
- package/dist/agents/jest-agent/SKILL.md +77 -0
- package/dist/agents/mobile-agent/SKILL.md +32 -0
- package/dist/agents/playwright-agent/SKILL.md +54 -0
- package/dist/agents/product-design-manager/SKILL.md +27 -0
- package/dist/agents/qa-agent/SKILL.md +29 -0
- package/dist/agents/quality-manager/SKILL.md +28 -0
- package/dist/agents/vitest-agent/SKILL.md +77 -0
- package/dist/governance/pre-push-rules.md +75 -0
- package/dist/governance/prompt-hygiene.md +22 -0
- package/dist/governance/review-checklist.md +24 -0
- package/dist/governance/safety-and-data.md +27 -0
- package/dist/governance/usage-rules.md +21 -0
- package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
- package/dist/mandatory/agentic-goal/SKILL.md +42 -0
- package/dist/mandatory/agentic-status/SKILL.md +74 -0
- package/dist/mandatory/apply-design/SKILL.md +228 -0
- package/dist/mandatory/check-ac/SKILL.md +124 -0
- package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
- package/dist/mandatory/design/SKILL.md +312 -0
- package/dist/mandatory/feature-workflow/SKILL.md +87 -0
- package/dist/mandatory/hotfix/SKILL.md +195 -0
- package/dist/mandatory/pre-push/SKILL.md +272 -0
- package/dist/mandatory/project-context/SKILL.md +180 -0
- package/dist/mandatory/release-docs/SKILL.md +191 -0
- package/dist/mandatory/review-pr/SKILL.md +323 -0
- package/dist/mandatory/security-review/SKILL.md +157 -0
- package/dist/skills/api-contract/SKILL.md +77 -0
- package/dist/skills/backend-django/SKILL.md +27 -0
- package/dist/skills/backend-nestjs/SKILL.md +65 -0
- package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
- package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
- package/dist/skills/database-migration/SKILL.md +97 -0
- package/dist/skills/debugging/SKILL.md +32 -0
- package/dist/skills/devops-infra/SKILL.md +26 -0
- package/dist/skills/documentation/SKILL.md +24 -0
- package/dist/skills/frontend-nextjs/SKILL.md +27 -0
- package/dist/skills/incident-postmortem/SKILL.md +38 -0
- package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
- package/dist/skills/mobile-react-native/SKILL.md +37 -0
- package/dist/skills/postgres-analytics/SKILL.md +29 -0
- package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
- package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
- package/dist/skills/refactoring/SKILL.md +32 -0
- package/dist/skills/sprint-planning/SKILL.md +32 -0
- package/dist/skills/testing-quality/SKILL.md +62 -0
- package/dist/v2/protocols/agent-orchestration.md +49 -0
- package/dist/v2/protocols/delivery.md +45 -0
- package/dist/v2/protocols/design-authority.md +29 -0
- package/dist/v2/protocols/goal-graph.md +44 -0
- package/dist/v2/protocols/pattern-first-development.md +29 -0
- package/dist/v2/protocols/qa-evidence.md +29 -0
- package/dist/v2/protocols/security.md +29 -0
- package/dist/v2/schemas/event.schema.json +86 -0
- package/dist/v2/schemas/evidence.schema.json +94 -0
- package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
- package/dist/v2/schemas/feature-plan.schema.json +95 -0
- package/dist/v2/schemas/goal-graph.schema.json +61 -0
- package/dist/v2/schemas/integration.schema.json +119 -0
- package/dist/v2/schemas/orchestration.schema.json +69 -0
- package/dist/v2/schemas/project.schema.json +209 -0
- package/dist/v2/schemas/providers.schema.json +142 -0
- package/dist/v2/schemas/quality.schema.json +50 -0
- package/dist/v2/schemas/work-action.schema.json +44 -0
- package/dist/v2/schemas/work-request.schema.json +253 -0
- package/dist/v2/templates/evidence/qa-bundle.json +194 -0
- package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
- package/dist/v2/templates/harness/SKILL.md +90 -0
- package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
- package/dist/v2/templates/project/.rivet/project.yaml +14 -0
- package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
- package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
- package/package.json +58 -0
- package/protocols/agent-orchestration.md +49 -0
- package/protocols/delivery.md +45 -0
- package/protocols/design-authority.md +29 -0
- package/protocols/goal-graph.md +44 -0
- package/protocols/pattern-first-development.md +29 -0
- package/protocols/qa-evidence.md +29 -0
- package/protocols/security.md +29 -0
- package/schemas/event.schema.json +86 -0
- package/schemas/evidence.schema.json +94 -0
- package/schemas/feature-decomposition.schema.json +48 -0
- package/schemas/feature-plan.schema.json +95 -0
- package/schemas/goal-graph.schema.json +61 -0
- package/schemas/integration.schema.json +119 -0
- package/schemas/orchestration.schema.json +69 -0
- package/schemas/project.schema.json +209 -0
- package/schemas/providers.schema.json +142 -0
- package/schemas/quality.schema.json +50 -0
- package/schemas/work-action.schema.json +44 -0
- package/schemas/work-request.schema.json +253 -0
- package/src/adapters/confluence.js +138 -0
- package/src/adapters/contract.js +657 -0
- package/src/adapters/factory.js +122 -0
- package/src/adapters/figma.js +178 -0
- package/src/adapters/fixtures.js +183 -0
- package/src/adapters/github.js +314 -0
- package/src/adapters/http.js +704 -0
- package/src/adapters/jira.js +195 -0
- package/src/adapters/linear.js +171 -0
- package/src/adapters/node-transport.js +59 -0
- package/src/cli/integration-setup-prompt.js +32 -0
- package/src/cli/interrupt.js +25 -0
- package/src/cli/main.js +596 -0
- package/src/cli/output.js +174 -0
- package/src/cli/parse-args.js +246 -0
- package/src/cli/project-discovery.js +70 -0
- package/src/cli/task-confirmation.js +38 -0
- package/src/cli/task-presentation.js +22 -0
- package/src/clients/claude.js +214 -0
- package/src/clients/codex.js +180 -0
- package/src/clients/compatibility.js +23 -0
- package/src/clients/contract.js +224 -0
- package/src/clients/fake.js +170 -0
- package/src/clients/process-runner.js +657 -0
- package/src/clients/result-contract.js +78 -0
- package/src/commands/delivery-publish.js +80 -0
- package/src/commands/delivery-remote.js +230 -0
- package/src/commands/delivery-review-update.js +39 -0
- package/src/commands/delivery-tracker.js +94 -0
- package/src/commands/delivery-transition.js +79 -0
- package/src/commands/delivery.js +223 -0
- package/src/commands/dependency-approval.js +21 -0
- package/src/commands/doctor.js +191 -0
- package/src/commands/evidence.js +44 -0
- package/src/commands/feature.js +212 -0
- package/src/commands/goals.js +231 -0
- package/src/commands/human-run.js +208 -0
- package/src/commands/human-task.js +278 -0
- package/src/commands/init.js +848 -0
- package/src/commands/install.js +814 -0
- package/src/commands/integration-setup.js +65 -0
- package/src/commands/integrations.js +54 -0
- package/src/commands/models.js +158 -0
- package/src/commands/orchestrate.js +61 -0
- package/src/commands/preflight.js +125 -0
- package/src/commands/protocols.js +228 -0
- package/src/commands/repositories.js +77 -0
- package/src/commands/setup-remote.js +41 -0
- package/src/commands/setup.js +214 -0
- package/src/commands/status.js +224 -0
- package/src/commands/support.js +44 -0
- package/src/commands/task-completion.js +104 -0
- package/src/commands/uninstall.js +234 -0
- package/src/commands/verify.js +171 -0
- package/src/commands/work.js +220 -0
- package/src/commands/worktrees.js +52 -0
- package/src/config/command-readiness.js +218 -0
- package/src/config/commands.js +206 -0
- package/src/config/defaults.js +55 -0
- package/src/config/load.js +230 -0
- package/src/config/validate.js +560 -0
- package/src/delivery/bitbucket-review.js +123 -0
- package/src/delivery/branch-publication.js +23 -0
- package/src/delivery/contract.js +379 -0
- package/src/delivery/github-deployment.js +132 -0
- package/src/delivery/github-review.js +5 -0
- package/src/delivery/github.js +382 -0
- package/src/delivery/gitlab-review.js +5 -0
- package/src/delivery/gitlab.js +395 -0
- package/src/delivery/prepare.js +90 -0
- package/src/delivery/publication-process.js +33 -0
- package/src/delivery/publication-transport.js +112 -0
- package/src/delivery/review-request.js +184 -0
- package/src/delivery/review-update.js +74 -0
- package/src/delivery/service.js +400 -0
- package/src/delivery/store.js +76 -0
- package/src/delivery/tracker-target.js +51 -0
- package/src/delivery/tracker-transition.js +158 -0
- package/src/delivery/tracker.js +164 -0
- package/src/discovery/git.js +85 -0
- package/src/discovery/portable.js +108 -0
- package/src/discovery/project.js +345 -0
- package/src/discovery/tools.js +145 -0
- package/src/evaluations/approval.js +20 -0
- package/src/evaluations/cost-policy.js +20 -0
- package/src/evaluations/harness-attempt.js +113 -0
- package/src/evaluations/live-runner.js +55 -0
- package/src/evaluations/profile.js +28 -0
- package/src/evaluations/report.js +11 -0
- package/src/evaluations/text-attempt.js +18 -0
- package/src/evidence/checksum.js +87 -0
- package/src/evidence/collect.js +727 -0
- package/src/evidence/validate.js +354 -0
- package/src/feature/accepted-integration.js +46 -0
- package/src/feature/actions.js +37 -0
- package/src/feature/client-profile.js +56 -0
- package/src/feature/decomposition-contract.js +58 -0
- package/src/feature/host-execution.js +633 -0
- package/src/feature/host-lock-recovery.js +96 -0
- package/src/feature/host-run-lock.js +32 -0
- package/src/feature/local-approval.js +183 -0
- package/src/feature/plan-contract.js +215 -0
- package/src/feature/planner.js +253 -0
- package/src/feature/run-store.js +241 -0
- package/src/feature/runtime-bridge.js +840 -0
- package/src/feature/verification-report.js +168 -0
- package/src/feature/workflow.js +375 -0
- package/src/git/client.js +509 -0
- package/src/git/integration-worktree.js +153 -0
- package/src/git/reconcile.js +222 -0
- package/src/git/reservations.js +450 -0
- package/src/git/worktrees.js +478 -0
- package/src/graph/completion.js +189 -0
- package/src/graph/fixtures.js +116 -0
- package/src/graph/reducer.js +124 -0
- package/src/graph/scheduler.js +252 -0
- package/src/graph/validate.js +106 -0
- package/src/install/managed.js +579 -0
- package/src/install/project-reference.js +33 -0
- package/src/install/project-runtime.js +142 -0
- package/src/install/runtime-integrity.cjs +141 -0
- package/src/integrations/capabilities.js +69 -0
- package/src/integrations/host-observation.js +26 -0
- package/src/integrations/registry.js +56 -0
- package/src/models/delegate.js +102 -0
- package/src/models/profiles.js +53 -0
- package/src/models/protocols.js +208 -0
- package/src/models/registry.js +83 -0
- package/src/models/transport.js +59 -0
- package/src/policy/approvals.js +251 -0
- package/src/policy/authority.js +282 -0
- package/src/policy/budget.js +149 -0
- package/src/policy/command-bootstrap.js +219 -0
- package/src/policy/commands.js +803 -0
- package/src/prompts/launch-contract.js +43 -0
- package/src/prompts/planning-contract.js +92 -0
- package/src/protocols/presentation.js +53 -0
- package/src/protocols/project.js +289 -0
- package/src/quality/runner.js +497 -0
- package/src/quality/traceability.js +215 -0
- package/src/repositories/identity.js +59 -0
- package/src/repositories/index.js +2 -0
- package/src/repositories/provider.js +210 -0
- package/src/runtime/application.js +353 -0
- package/src/runtime/dependency-directory.js +33 -0
- package/src/runtime/harness-discovery.js +121 -0
- package/src/runtime/heartbeat.js +47 -0
- package/src/runtime/instance-store.js +112 -0
- package/src/runtime/orchestrator.js +1133 -0
- package/src/runtime/portable-dependencies.js +94 -0
- package/src/runtime/recovery.js +190 -0
- package/src/runtime/retry.js +54 -0
- package/src/runtime/supervisor.js +166 -0
- package/src/runtime/worktree-bootstrap.js +169 -0
- package/src/state/event-store.js +333 -0
- package/src/state/lock.js +266 -0
- package/src/state/paths.js +274 -0
- package/src/state/redact.js +106 -0
- package/src/state/snapshot-store.js +310 -0
- package/src/status/public/app.js +96 -0
- package/src/status/public/index.html +37 -0
- package/src/status/public/styles.css +50 -0
- package/src/status/server.js +323 -0
- package/src/status/view-model.js +356 -0
- package/src/support/bundle.js +195 -0
- package/src/support/failure-report.js +96 -0
- package/src/work-request/contract.js +250 -0
- package/src/work-request/host.js +78 -0
- package/src/work-request/local.js +163 -0
- package/src/work-request/tracker.js +105 -0
- package/templates/evidence/qa-bundle.json +194 -0
- package/templates/github-actions/rivet-deploy.yml +98 -0
- package/templates/harness/SKILL.md +90 -0
- package/templates/project/.rivet/orchestration.yaml +42 -0
- package/templates/project/.rivet/project.yaml +14 -0
- package/templates/project/.rivet/providers.yaml +8 -0
- package/templates/project/.rivet/quality.yaml +16 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { captureRecord, createProviderWireBody } from '../adapters/contract.js';
|
|
2
|
+
import { createProviderHttpClient } from '../adapters/http.js';
|
|
3
|
+
import { parseRepositoryRemote } from '../repositories/identity.js';
|
|
4
|
+
import { createTrustedDeliveryExecutor } from './service.js';
|
|
5
|
+
import { reviewRequestContent } from './review-request.js';
|
|
6
|
+
import { digest, ensure, exact, factsDigest, hash, plain, sha, timestamp, validateCandidate } from './contract.js';
|
|
7
|
+
|
|
8
|
+
const API='https://api.bitbucket.org/2.0';
|
|
9
|
+
const STATES=['OPEN','MERGED','DECLINED','SUPERSEDED'];
|
|
10
|
+
const UUID=/^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\}$/i;
|
|
11
|
+
// Bitbucket accepts refs rather than an expected commit condition. Creation is
|
|
12
|
+
// confirmed only after exact readback and stable repository/branch observations.
|
|
13
|
+
export function createBitbucketReviewExecutor(inputConfig) {
|
|
14
|
+
const input=captureRecord(inputConfig,new Set(['repository','transport','headers','clock','timeoutMs']),['repository','transport'],'invalid-config');
|
|
15
|
+
const supplied=plain(input.repository),repository=parseRepositoryRemote(supplied.url);
|
|
16
|
+
ensure(repository.provider==='bitbucket'&&hash(repository)===hash(supplied));
|
|
17
|
+
const clock=input.clock??(()=>new Date().toISOString());ensure(typeof clock==='function');
|
|
18
|
+
const http=createProviderHttpClient({provider:'bitbucket',baseUrl:API,transport:input.transport,allowEncodedSlash:true,
|
|
19
|
+
...(input.headers===undefined?{}:{headers:input.headers}),...(input.timeoutMs===undefined?{}:{timeoutMs:input.timeoutMs})});
|
|
20
|
+
const root=`/repositories/${repository.fullName}`,listPath=root+'/pullrequests';
|
|
21
|
+
const get=async path=>(await http.request({method:'GET',path:root+path})).data;
|
|
22
|
+
function number(value){ensure(Number.isSafeInteger(value)&&value>0,'invalid-provider-evidence');return value;}
|
|
23
|
+
function target(value){validateCandidate(value);ensure(hash(value.repository)===hash(repository),'provider-mismatch');}
|
|
24
|
+
function identity(value,embedded=false){
|
|
25
|
+
ensure(typeof value?.uuid==='string'&&UUID.test(value.uuid)&&value.full_name===repository.fullName&&value.links?.html?.href===repository.url,'changed-facts');
|
|
26
|
+
let workspace=null;
|
|
27
|
+
if(!embedded||value.workspace!==undefined){ensure(typeof value.workspace?.uuid==='string'&&UUID.test(value.workspace.uuid)&&value.workspace.slug===repository.namespace,'changed-facts');workspace={uuid:value.workspace.uuid,slug:value.workspace.slug};}
|
|
28
|
+
return {uuid:value.uuid,fullName:repository.fullName,url:repository.url,workspace};
|
|
29
|
+
}
|
|
30
|
+
async function branches(candidate){
|
|
31
|
+
const project=identity(await get(''));
|
|
32
|
+
const branch=async name=>{
|
|
33
|
+
let result;try{result=await get(`/refs/branches/${encodeURIComponent(name)}`);}catch(error){
|
|
34
|
+
if(error?.status===404){const failure=new Error('Publish the verified source branch and ensure the target branch exists before requesting review.');failure.code='ERR_DELIVERY_BRANCH_NOT_PUBLISHED';failure.safeMessage=failure.message;throw failure;}throw error;
|
|
35
|
+
}
|
|
36
|
+
ensure(result?.name===name,'changed-facts');return sha(result.target?.hash);
|
|
37
|
+
};
|
|
38
|
+
const headSha=await branch(candidate.sourceBranch),baseSha=await branch(candidate.targetBranch);
|
|
39
|
+
ensure(headSha===candidate.headSha,'changed-facts');return {project,headSha,baseSha};
|
|
40
|
+
}
|
|
41
|
+
function rawBody(value){
|
|
42
|
+
const fields=[value.description,value.summary?.raw,value.rendered?.description?.raw].filter(field=>field!==undefined);
|
|
43
|
+
ensure(fields.length>0&&fields.every(field=>typeof field==='string'&&field===fields[0]),'invalid-provider-evidence');
|
|
44
|
+
return fields[0];
|
|
45
|
+
}
|
|
46
|
+
function review(value,candidate,before){
|
|
47
|
+
const id=number(value?.id);
|
|
48
|
+
ensure(STATES.includes(value.state)&&typeof value.draft==='boolean'&&value.links?.html?.href===`${repository.url}/pull-requests/${id}`,'changed-facts');
|
|
49
|
+
for(const [side,name,commit] of [['source',candidate.sourceBranch,candidate.headSha],['destination',candidate.targetBranch,before.baseSha]]){
|
|
50
|
+
const part=value[side],repo=identity(part?.repository,true);
|
|
51
|
+
ensure(repo.uuid===before.project.uuid&&(repo.workspace===null||hash(repo.workspace)===hash(before.project.workspace))
|
|
52
|
+
&&part.branch?.name===name&&part.commit?.hash===commit,'changed-facts');
|
|
53
|
+
}
|
|
54
|
+
ensure(typeof value.title==='string','changed-facts');const body=rawBody(value);
|
|
55
|
+
return plain({number:id,state:value.state==='OPEN'?'open':value.state==='MERGED'?'merged':'closed',draft:value.draft,url:value.links.html.href,
|
|
56
|
+
headSha:candidate.headSha,baseSha:before.baseSha,title:value.title,body});
|
|
57
|
+
}
|
|
58
|
+
// JSON next links are API input: pin all selectors and reject hidden partial
|
|
59
|
+
// responses. Bounds and duplicate IDs apply across all four state queries.
|
|
60
|
+
async function listed(candidate){
|
|
61
|
+
const result=[],ids=new Set(),visited=new Set();let pages=0;
|
|
62
|
+
const q=`source.branch.name=${JSON.stringify(candidate.sourceBranch)} AND destination.branch.name=${JSON.stringify(candidate.targetBranch)}`;
|
|
63
|
+
for(const state of STATES){
|
|
64
|
+
let declaredSize=null,received=0;
|
|
65
|
+
let next=new URL(API+listPath);next.search=new URLSearchParams({q,state,pagelen:'100'}).toString();
|
|
66
|
+
while(next){
|
|
67
|
+
ensure(++pages<=20&&next.origin==='https://api.bitbucket.org'&&next.pathname==='/2.0'+listPath&&!next.username&&!next.password&&!next.hash,'invalid-pagination');
|
|
68
|
+
const params=next.searchParams,keys=[...params.keys()];
|
|
69
|
+
ensure(new Set(keys).size===keys.length&&keys.every(key=>['q','state','pagelen','page'].includes(key))
|
|
70
|
+
&¶ms.get('q')===q&¶ms.get('state')===state&¶ms.get('pagelen')==='100'
|
|
71
|
+
&&(!params.has('page')||/^[1-9][0-9]{0,5}$/.test(params.get('page'))),'invalid-pagination');
|
|
72
|
+
const canonical=new URL(next);canonical.searchParams.sort();ensure(!visited.has(canonical.href),'invalid-pagination');visited.add(canonical.href);
|
|
73
|
+
const requestedPage=Number(params.get('page')??'1');
|
|
74
|
+
const page=(await http.request({method:'GET',path:next.pathname.slice('/2.0'.length)+next.search})).data;
|
|
75
|
+
ensure(Array.isArray(page?.values)&&page.values.length<=100,'invalid-pagination');
|
|
76
|
+
if(page.page!==undefined)ensure(page.page===requestedPage,'invalid-pagination');
|
|
77
|
+
if(page.pagelen!==undefined)ensure(page.pagelen===100,'invalid-pagination');
|
|
78
|
+
if(page.size!==undefined){ensure(Number.isSafeInteger(page.size)&&page.size>=0&&page.size<=1000&&(declaredSize===null||declaredSize===page.size),'invalid-pagination');declaredSize=page.size;}
|
|
79
|
+
received+=page.values.length;ensure(declaredSize===null||received<=declaredSize,'invalid-pagination');
|
|
80
|
+
for(const row of page.values){const id=number(row?.id);ensure(!ids.has(id)&&row.state===state,'invalid-pagination');ids.add(id);result.push(row);ensure(result.length<=1000,'invalid-pagination');}
|
|
81
|
+
if(page.next===undefined||page.next===null){ensure(declaredSize===null||received===declaredSize,'invalid-pagination');break;}
|
|
82
|
+
ensure(page.values.length>0&&typeof page.next==='string'&&page.next.length<=8192,'invalid-pagination');
|
|
83
|
+
try{next=new URL(page.next);}catch{ensure(false,'invalid-pagination');}
|
|
84
|
+
ensure(next.searchParams.get('page')===String(requestedPage+1),'invalid-pagination');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
function facts(candidate,before,existing){
|
|
90
|
+
const unknown={headSha:candidate.headSha,policy:'unknown',satisfied:null,evidenceDigest:hash({before,existing})};
|
|
91
|
+
return plain({repositoryUrl:repository.url,sourceBranch:candidate.sourceBranch,targetBranch:candidate.targetBranch,headSha:candidate.headSha,baseSha:before.baseSha,
|
|
92
|
+
review:existing?{number:existing.number,state:existing.state,url:existing.url,headSha:candidate.headSha}:null,checks:unknown,reviews:unknown,observedAt:timestamp(clock())});
|
|
93
|
+
}
|
|
94
|
+
async function observe(candidate){
|
|
95
|
+
target(candidate);const before=await branches(candidate),rows=await listed(candidate);ensure(rows.length<=1,'ambiguous-review');let existing=null;
|
|
96
|
+
if(rows.length){const selected=number(rows[0].id);existing=review(await get(`/pullrequests/${selected}`),candidate,before);ensure(existing.number===selected&&(candidate.reviewNumber===null||candidate.reviewNumber===selected),'changed-facts');}
|
|
97
|
+
else ensure(candidate.reviewNumber===null,'changed-facts');
|
|
98
|
+
ensure(hash(await branches(candidate))===hash(before),'changed-facts');return facts(candidate,before,existing);
|
|
99
|
+
}
|
|
100
|
+
function operation(value){const op=plain(value);target(op.candidate);digest(op.digest);digest(op.factsDigest);ensure(op.action==='review-request'&&op.candidate.reviewNumber===null,'unsupported-action');reviewRequestContent(op);return op;}
|
|
101
|
+
function verified(value,op,before){const result=review(value,op.candidate,before),content=reviewRequestContent(op);ensure(!result.draft&&value.close_source_branch===false&&result.title===content.title&&result.body===content.body,'unverified-effect');return result;}
|
|
102
|
+
function receipt(op,result){return plain({status:'succeeded',operationDigest:op.digest,headSha:op.candidate.headSha,evidenceDigest:hash(result),resourceUrl:result.url,commitSha:null});}
|
|
103
|
+
async function marked(op,before){
|
|
104
|
+
const marker=`<!-- rivet-review-operation:${op.digest} -->`,rows=await listed(op.candidate);
|
|
105
|
+
const matches=rows.filter(row=>rawBody(row).includes(marker));ensure(matches.length===1,'ambiguous-review');
|
|
106
|
+
const selected=number(matches[0].id),result=verified(await get(`/pullrequests/${selected}`),op,before);ensure(result.number===selected,'changed-facts');return result;
|
|
107
|
+
}
|
|
108
|
+
async function dispatch(value,context){
|
|
109
|
+
const op=operation(value),limits=plain(context);exact(limits,['deadline']);const deadline=Date.parse(timestamp(limits.deadline));
|
|
110
|
+
const timely=()=>ensure(Date.parse(timestamp(clock()))<deadline,'dispatch-expired');timely();
|
|
111
|
+
const observed=await observe(op.candidate);ensure(observed.review===null&&factsDigest(observed)===op.factsDigest,'changed-facts');
|
|
112
|
+
const content=reviewRequestContent(op),wireBody=createProviderWireBody({provider:'bitbucket',action:'review-request',resourceId:repository.fullName,expectedState:'absent',expectedVersion:op.candidate.headSha,idempotencyKey:op.digest,
|
|
113
|
+
payload:{title:content.title,description:content.body,source:{branch:{name:op.candidate.sourceBranch}},destination:{branch:{name:op.candidate.targetBranch}},draft:false,close_source_branch:false}});
|
|
114
|
+
timely();const controller=new AbortController(),remaining=deadline-Date.parse(timestamp(clock()));ensure(remaining>0,'dispatch-expired');const timer=setTimeout(()=>controller.abort(),remaining);
|
|
115
|
+
let response;try{response=await http.request({method:'POST',path:listPath,wireBody,signal:controller.signal});}finally{clearTimeout(timer);}
|
|
116
|
+
ensure(response.status===201,'unverified-effect');const after=await branches(op.candidate);
|
|
117
|
+
ensure(hash({before:after,existing:null})===observed.checks.evidenceDigest,'changed-facts');
|
|
118
|
+
const sent=verified(response.data,op,after);ensure(sent.state==='open','unverified-effect');
|
|
119
|
+
const result=await marked(op,after);ensure(hash(sent)===hash(result)&&hash(await branches(op.candidate))===hash(after),'changed-facts');return receipt(op,result);
|
|
120
|
+
}
|
|
121
|
+
async function reconcile(value){try{const op=operation(value),before=await branches(op.candidate);ensure(factsDigest(facts(op.candidate,before,null))===op.factsDigest,'changed-facts');const result=await marked(op,before);ensure(hash(await branches(op.candidate))===hash(before),'changed-facts');return {status:'succeeded',receipt:receipt(op,result)};}catch{return {status:'unknown'};}}
|
|
122
|
+
return createTrustedDeliveryExecutor({provider:'bitbucket',capabilities:[{action:'review-request',conditionalHead:false,verifiesCreatedReview:true,reconcile:true}],observe,dispatch,reconcile});
|
|
123
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createTrustedDeliveryExecutor } from './service.js';
|
|
2
|
+
import { ensure, exact, hash, sha, factsDigest } from './contract.js';
|
|
3
|
+
import { publicationRef } from './publication-transport.js';
|
|
4
|
+
export function publicationPayload(target) {return {destinationUrl:target.repository.url+'.git',ref:publicationRef(target.sourceBranch),headSha:target.headSha};}
|
|
5
|
+
export function createBranchPublicationExecutor({repository,transport}) {
|
|
6
|
+
ensure(transport&&typeof transport.readRefs==='function'&&typeof transport.push==='function');
|
|
7
|
+
const validate=op=>{ensure(op.action==='branch-publish'&&hash(op.candidate.repository)===hash(repository));ensure(hash(op.payload)===hash(publicationPayload(op.candidate)),'invalid-publication');};
|
|
8
|
+
const inspect=async target=>{
|
|
9
|
+
ensure(hash(target.repository)===hash(repository));const refs=await transport.readRefs(target);exact(refs,['sourceSha','baseSha']);sha(refs.baseSha);if(refs.sourceSha!==null)sha(refs.sourceSha);return refs;
|
|
10
|
+
};
|
|
11
|
+
const receipt=(op,refs)=>({status:'succeeded',operationDigest:op.digest,headSha:op.candidate.headSha,commitSha:op.candidate.headSha,resourceUrl:repository.url,evidenceDigest:hash({assurance:'publication-requirement-satisfied',publication:op.payload,remoteSha:refs.sourceSha})});
|
|
12
|
+
async function observe(target) {
|
|
13
|
+
const refs=await inspect(target);const policy={headSha:target.headSha,policy:'unknown',satisfied:null,evidenceDigest:hash({publication:refs})};
|
|
14
|
+
return {repositoryUrl:repository.url,sourceBranch:target.sourceBranch,targetBranch:target.targetBranch,headSha:target.headSha,baseSha:refs.baseSha,review:null,checks:policy,reviews:policy,observedAt:new Date().toISOString(),publication:{destinationUrl:repository.url+'.git',ref:publicationRef(target.sourceBranch),remoteSha:refs.sourceSha}};
|
|
15
|
+
}
|
|
16
|
+
return createTrustedDeliveryExecutor({provider:repository.provider,capabilities:[{action:'branch-publish',conditionalHead:true,reconcile:true}],observe,
|
|
17
|
+
async dispatch(op,context) {
|
|
18
|
+
validate(op);const current=await observe(op.candidate);ensure(current.publication.remoteSha===null,'branch-exists');ensure(factsDigest(current)===op.factsDigest,'changed-facts');
|
|
19
|
+
await transport.push(op.candidate,context);const refs=await inspect(op.candidate);ensure(refs.sourceSha===op.candidate.headSha,'publication-outcome-uncertain');return receipt(op,refs);
|
|
20
|
+
},
|
|
21
|
+
async reconcile(op) {validate(op);const refs=await inspect(op.candidate);return refs.sourceSha===op.candidate.headSha?{status:'succeeded',receipt:receipt(op,refs)}:{status:'unknown'};},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { containsSecretMaterial } from '../clients/contract.js';
|
|
3
|
+
import { parseRepositoryRemote } from '../repositories/identity.js';
|
|
4
|
+
|
|
5
|
+
export const ACTIONS = Object.freeze(['branch-publish', 'review-request', 'review-update', 'merge', 'deploy', 'tracker-update', 'tracker-transition']);
|
|
6
|
+
export const STAGES = Object.freeze([
|
|
7
|
+
'locally-verified',
|
|
8
|
+
'branch-published',
|
|
9
|
+
'review-requested',
|
|
10
|
+
'checks-passed',
|
|
11
|
+
'merge-approved',
|
|
12
|
+
'merged',
|
|
13
|
+
'deployed',
|
|
14
|
+
'tracker-updated',
|
|
15
|
+
'tracker-status-confirmed',
|
|
16
|
+
]);
|
|
17
|
+
export class DeliveryError extends Error {
|
|
18
|
+
constructor(reason = 'invalid-input') {
|
|
19
|
+
super(`Delivery operation failed: ${reason}.`);
|
|
20
|
+
this.name = 'DeliveryError';
|
|
21
|
+
this.code = `ERR_DELIVERY_${reason.replaceAll('-', '_').toUpperCase()}`;
|
|
22
|
+
this.safeMessage = this.message;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function fail(reason) {
|
|
26
|
+
throw new DeliveryError(reason);
|
|
27
|
+
}
|
|
28
|
+
export function ensure(value, reason = 'invalid-input') {
|
|
29
|
+
if (!value) fail(reason);
|
|
30
|
+
}
|
|
31
|
+
export function plain(input) {
|
|
32
|
+
let nodes = 0;
|
|
33
|
+
function copy(value, depth) {
|
|
34
|
+
ensure(++nodes <= 10000 && depth <= 20);
|
|
35
|
+
if (value === null || typeof value === 'boolean') return value;
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
ensure(value.length <= 65536 && !containsSecretMaterial(value));
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === 'number') {
|
|
41
|
+
ensure(Number.isSafeInteger(value));
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
ensure(value && typeof value === 'object');
|
|
45
|
+
const array = Array.isArray(value);
|
|
46
|
+
ensure(
|
|
47
|
+
Object.getPrototypeOf(value) === (array ? Array.prototype : Object.prototype) ||
|
|
48
|
+
(!array && Object.getPrototypeOf(value) === null)
|
|
49
|
+
);
|
|
50
|
+
const out = array ? [] : {};
|
|
51
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
52
|
+
if (array && key === 'length') continue;
|
|
53
|
+
ensure(typeof key === 'string' && key !== '__proto__');
|
|
54
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
55
|
+
ensure(descriptor?.enumerable && Object.hasOwn(descriptor, 'value'));
|
|
56
|
+
if (array) ensure(/^(?:0|[1-9][0-9]*)$/.test(key) && Number(key) < value.length);
|
|
57
|
+
else ensure(!containsSecretMaterial(`${key}: sensitive-value`));
|
|
58
|
+
out[key] = copy(descriptor.value, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
if (array) ensure(out.length === value.length && Object.keys(out).length === value.length);
|
|
61
|
+
return Object.freeze(out);
|
|
62
|
+
}
|
|
63
|
+
const result = copy(input, 0);
|
|
64
|
+
ensure(Buffer.byteLength(JSON.stringify(result)) <= 262144);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
export function exact(value, keys, optional = []) {
|
|
68
|
+
ensure(value && typeof value === 'object' && !Array.isArray(value));
|
|
69
|
+
ensure(
|
|
70
|
+
Object.keys(value).every((key) => keys.includes(key) || optional.includes(key)) &&
|
|
71
|
+
keys.every((key) => Object.hasOwn(value, key))
|
|
72
|
+
);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
export function id(value) {
|
|
76
|
+
ensure(typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value));
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
export function sha(value) {
|
|
80
|
+
ensure(typeof value === 'string' && /^[a-f0-9]{40}$/.test(value));
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
export function digest(value) {
|
|
84
|
+
ensure(typeof value === 'string' && /^[a-f0-9]{64}$/.test(value));
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
export function timestamp(value) {
|
|
88
|
+
ensure(
|
|
89
|
+
typeof value === 'string' &&
|
|
90
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) &&
|
|
91
|
+
Number.isFinite(Date.parse(value)) &&
|
|
92
|
+
new Date(value).toISOString() === value
|
|
93
|
+
);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
export function ref(value) {
|
|
97
|
+
ensure(
|
|
98
|
+
typeof value === 'string' &&
|
|
99
|
+
/^[A-Za-z0-9_][A-Za-z0-9._/-]{0,254}$/.test(value) &&
|
|
100
|
+
!value.includes('..') &&
|
|
101
|
+
!value.includes('//')
|
|
102
|
+
);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
export function hash(value) {
|
|
106
|
+
const canonical = (item) =>
|
|
107
|
+
item === null || typeof item !== 'object'
|
|
108
|
+
? JSON.stringify(item)
|
|
109
|
+
: Array.isArray(item)
|
|
110
|
+
? `[${item.map(canonical).join(',')}]`
|
|
111
|
+
: `{${Object.keys(item)
|
|
112
|
+
.sort()
|
|
113
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(item[key])}`)
|
|
114
|
+
.join(',')}}`;
|
|
115
|
+
return createHash('sha256').update(canonical(value)).digest('hex');
|
|
116
|
+
}
|
|
117
|
+
export function candidate(input) {
|
|
118
|
+
const value = plain(input);
|
|
119
|
+
exact(
|
|
120
|
+
value,
|
|
121
|
+
['runId', 'repository', 'sourceBranch', 'targetBranch', 'localVerification'],
|
|
122
|
+
['reviewNumber']
|
|
123
|
+
);
|
|
124
|
+
id(value.runId);
|
|
125
|
+
ref(value.sourceBranch);
|
|
126
|
+
ref(value.targetBranch);
|
|
127
|
+
const repository = parseRepositoryRemote(value.repository.url);
|
|
128
|
+
ensure(hash(repository) === hash(value.repository));
|
|
129
|
+
const proof = exact(value.localVerification, [
|
|
130
|
+
'runId',
|
|
131
|
+
'headSha',
|
|
132
|
+
'evidenceDigest',
|
|
133
|
+
'verifiedAt',
|
|
134
|
+
'status',
|
|
135
|
+
]);
|
|
136
|
+
ensure(proof.runId === value.runId && proof.status === 'passed');
|
|
137
|
+
sha(proof.headSha);
|
|
138
|
+
digest(proof.evidenceDigest);
|
|
139
|
+
timestamp(proof.verifiedAt);
|
|
140
|
+
ensure(
|
|
141
|
+
value.reviewNumber === undefined || (Number.isSafeInteger(value.reviewNumber) && value.reviewNumber > 0)
|
|
142
|
+
);
|
|
143
|
+
return plain({ ...value, headSha: proof.headSha, reviewNumber: value.reviewNumber ?? null });
|
|
144
|
+
}
|
|
145
|
+
export function validateCandidate(value) {
|
|
146
|
+
const { headSha, reviewNumber, ...input } = value;
|
|
147
|
+
const result = candidate({ ...input, ...(reviewNumber === null ? {} : { reviewNumber }) });
|
|
148
|
+
ensure(result.headSha === headSha);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
export function observation(input, target) {
|
|
152
|
+
const value = plain(input);
|
|
153
|
+
exact(value, [
|
|
154
|
+
'repositoryUrl',
|
|
155
|
+
'sourceBranch',
|
|
156
|
+
'targetBranch',
|
|
157
|
+
'headSha',
|
|
158
|
+
'baseSha',
|
|
159
|
+
'review',
|
|
160
|
+
'checks',
|
|
161
|
+
'reviews',
|
|
162
|
+
'observedAt',
|
|
163
|
+
], ['publication']);
|
|
164
|
+
if (value.publication !== undefined) {
|
|
165
|
+
const p = exact(value.publication, ['destinationUrl', 'ref', 'remoteSha']);
|
|
166
|
+
ensure(p.destinationUrl === target.repository.url + '.git' && p.ref === `refs/heads/${target.sourceBranch}`, 'changed-facts');
|
|
167
|
+
ensure(p.remoteSha === null || sha(p.remoteSha), 'changed-facts');
|
|
168
|
+
}
|
|
169
|
+
timestamp(value.observedAt);
|
|
170
|
+
sha(value.baseSha);
|
|
171
|
+
ensure(
|
|
172
|
+
value.repositoryUrl === target.repository.url &&
|
|
173
|
+
value.sourceBranch === target.sourceBranch &&
|
|
174
|
+
value.targetBranch === target.targetBranch &&
|
|
175
|
+
value.headSha === target.headSha,
|
|
176
|
+
'changed-facts'
|
|
177
|
+
);
|
|
178
|
+
if (value.review !== null) {
|
|
179
|
+
const review = exact(value.review, ['number', 'state', 'url', 'headSha']);
|
|
180
|
+
ensure(
|
|
181
|
+
Number.isSafeInteger(review.number) &&
|
|
182
|
+
review.number > 0 &&
|
|
183
|
+
(target.reviewNumber === null || review.number === target.reviewNumber)
|
|
184
|
+
);
|
|
185
|
+
ensure(
|
|
186
|
+
['open', 'closed', 'merged'].includes(review.state) && review.headSha === target.headSha,
|
|
187
|
+
'changed-facts'
|
|
188
|
+
);
|
|
189
|
+
const segment = { github: 'pull', bitbucket: 'pull-requests', gitlab: '-/merge_requests' }[
|
|
190
|
+
target.repository.provider
|
|
191
|
+
];
|
|
192
|
+
ensure(review.url === `${target.repository.url}/${segment}/${review.number}`);
|
|
193
|
+
}
|
|
194
|
+
for (const key of ['checks', 'reviews']) {
|
|
195
|
+
const policy = exact(value[key], ['headSha', 'policy', 'satisfied', 'evidenceDigest']);
|
|
196
|
+
ensure(policy.headSha === target.headSha, 'changed-facts');
|
|
197
|
+
digest(policy.evidenceDigest);
|
|
198
|
+
ensure(
|
|
199
|
+
policy.policy === 'unknown'
|
|
200
|
+
? policy.satisfied === null
|
|
201
|
+
: policy.policy === 'known' && typeof policy.satisfied === 'boolean'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
export function ready(value) {
|
|
207
|
+
return (
|
|
208
|
+
value?.review?.state === 'open' &&
|
|
209
|
+
['checks', 'reviews'].every((key) => value[key].policy === 'known' && value[key].satisfied === true)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
export function factsDigest(value) {
|
|
213
|
+
const { observedAt, ...facts } = value;
|
|
214
|
+
return hash(facts);
|
|
215
|
+
}
|
|
216
|
+
export function validateReceipt(input, operation) {
|
|
217
|
+
const value = plain(input);
|
|
218
|
+
exact(value, ['status', 'operationDigest', 'headSha', 'evidenceDigest', 'resourceUrl', 'commitSha']);
|
|
219
|
+
ensure(
|
|
220
|
+
value.status === 'succeeded' &&
|
|
221
|
+
value.operationDigest === operation.digest &&
|
|
222
|
+
value.headSha === operation.candidate.headSha,
|
|
223
|
+
'invalid-receipt'
|
|
224
|
+
);
|
|
225
|
+
digest(value.evidenceDigest);
|
|
226
|
+
let url;
|
|
227
|
+
try {
|
|
228
|
+
url = new URL(value.resourceUrl);
|
|
229
|
+
} catch {
|
|
230
|
+
fail('invalid-receipt');
|
|
231
|
+
}
|
|
232
|
+
ensure(
|
|
233
|
+
url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash,
|
|
234
|
+
'invalid-receipt'
|
|
235
|
+
);
|
|
236
|
+
ensure(value.commitSha === null || sha(value.commitSha));
|
|
237
|
+
if (operation.action === 'merge') sha(value.commitSha);
|
|
238
|
+
if (operation.action === 'branch-publish') ensure(value.commitSha === operation.candidate.headSha && value.resourceUrl === operation.candidate.repository.url, 'invalid-receipt');
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
export function mergeReceiptFor(operations, action) {
|
|
242
|
+
if (!['deploy', 'tracker-update', 'tracker-transition'].includes(action)) return null;
|
|
243
|
+
const merge = operations.find(
|
|
244
|
+
(operation) => operation.action === 'merge' && operation.state === 'succeeded'
|
|
245
|
+
);
|
|
246
|
+
ensure(merge?.receipt?.commitSha, 'missing-merge-receipt');
|
|
247
|
+
return merge.receipt;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function publicationPayload(value, target) {
|
|
251
|
+
exact(value, ['destinationUrl', 'ref', 'headSha']);
|
|
252
|
+
ensure(value.destinationUrl === target.repository.url + '.git' && value.ref === `refs/heads/${target.sourceBranch}` && value.headSha === target.headSha, 'invalid-publication');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function validateRecord(input) {
|
|
256
|
+
const value = plain(input);
|
|
257
|
+
exact(value, [
|
|
258
|
+
'schemaVersion',
|
|
259
|
+
'stage',
|
|
260
|
+
'candidate',
|
|
261
|
+
'observation',
|
|
262
|
+
'proposal',
|
|
263
|
+
'operations',
|
|
264
|
+
'usedApprovalIds',
|
|
265
|
+
'createdAt',
|
|
266
|
+
'updatedAt',
|
|
267
|
+
]);
|
|
268
|
+
ensure(value.schemaVersion === 1 && STAGES.includes(value.stage));
|
|
269
|
+
validateCandidate(value.candidate);
|
|
270
|
+
timestamp(value.createdAt);
|
|
271
|
+
timestamp(value.updatedAt);
|
|
272
|
+
ensure(value.updatedAt >= value.createdAt);
|
|
273
|
+
if (value.observation !== null) observation(value.observation, value.candidate);
|
|
274
|
+
ensure(
|
|
275
|
+
Array.isArray(value.operations) &&
|
|
276
|
+
value.operations.length <= 64 &&
|
|
277
|
+
Array.isArray(value.usedApprovalIds) &&
|
|
278
|
+
value.usedApprovalIds.length <= 64 &&
|
|
279
|
+
new Set(value.usedApprovalIds).size === value.usedApprovalIds.length
|
|
280
|
+
);
|
|
281
|
+
for (const operation of value.operations) {
|
|
282
|
+
exact(operation, [
|
|
283
|
+
'digest',
|
|
284
|
+
'action',
|
|
285
|
+
'payload',
|
|
286
|
+
'candidate',
|
|
287
|
+
'factsDigest',
|
|
288
|
+
'mergeReceipt',
|
|
289
|
+
'approvalId',
|
|
290
|
+
'dispatchedAt',
|
|
291
|
+
'state',
|
|
292
|
+
'receipt',
|
|
293
|
+
]);
|
|
294
|
+
ensure(
|
|
295
|
+
hash(operation.mergeReceipt) ===
|
|
296
|
+
hash(
|
|
297
|
+
mergeReceiptFor(value.operations.slice(0, value.operations.indexOf(operation)), operation.action)
|
|
298
|
+
),
|
|
299
|
+
'merge-receipt-mismatch'
|
|
300
|
+
);
|
|
301
|
+
if (operation.action === 'branch-publish') publicationPayload(operation.payload, value.candidate);
|
|
302
|
+
digest(operation.digest);
|
|
303
|
+
digest(operation.factsDigest);
|
|
304
|
+
id(operation.approvalId);
|
|
305
|
+
timestamp(operation.dispatchedAt);
|
|
306
|
+
ensure(
|
|
307
|
+
ACTIONS.includes(operation.action) &&
|
|
308
|
+
['dispatching', 'indeterminate', 'succeeded', 'not-applied'].includes(operation.state)
|
|
309
|
+
);
|
|
310
|
+
ensure(
|
|
311
|
+
hash(operation.candidate) === hash(value.candidate) &&
|
|
312
|
+
value.usedApprovalIds.includes(operation.approvalId)
|
|
313
|
+
);
|
|
314
|
+
if (operation.receipt !== null) {
|
|
315
|
+
ensure(operation.state === 'succeeded');
|
|
316
|
+
validateReceipt(operation.receipt, operation);
|
|
317
|
+
} else ensure(operation.state !== 'succeeded');
|
|
318
|
+
}
|
|
319
|
+
if (value.proposal !== null) {
|
|
320
|
+
const p = exact(value.proposal, [
|
|
321
|
+
'digest',
|
|
322
|
+
'action',
|
|
323
|
+
'payload',
|
|
324
|
+
'expiresAt',
|
|
325
|
+
'factsDigest',
|
|
326
|
+
'nonce',
|
|
327
|
+
'resource',
|
|
328
|
+
'approvalResource',
|
|
329
|
+
'providerId',
|
|
330
|
+
'mergeReceipt',
|
|
331
|
+
]);
|
|
332
|
+
ensure(
|
|
333
|
+
hash(p.mergeReceipt) === hash(mergeReceiptFor(value.operations, p.action)),
|
|
334
|
+
'merge-receipt-mismatch'
|
|
335
|
+
);
|
|
336
|
+
if (p.action === 'branch-publish') publicationPayload(p.payload, value.candidate);
|
|
337
|
+
digest(p.digest);
|
|
338
|
+
digest(p.factsDigest);
|
|
339
|
+
timestamp(p.expiresAt);
|
|
340
|
+
id(p.providerId);
|
|
341
|
+
ensure(ACTIONS.includes(p.action) && typeof p.nonce === 'string');
|
|
342
|
+
const body = {
|
|
343
|
+
candidate: value.candidate,
|
|
344
|
+
action: p.action,
|
|
345
|
+
payload: p.payload,
|
|
346
|
+
expiresAt: p.expiresAt,
|
|
347
|
+
factsDigest: p.factsDigest,
|
|
348
|
+
nonce: p.nonce,
|
|
349
|
+
providerId: p.providerId,
|
|
350
|
+
mergeReceipt: p.mergeReceipt,
|
|
351
|
+
};
|
|
352
|
+
ensure(
|
|
353
|
+
hash(body) === p.digest &&
|
|
354
|
+
p.resource === `delivery:${p.digest}` &&
|
|
355
|
+
p.approvalResource === `${p.providerId}:${p.action}:${p.resource}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
const success = (action) => value.operations.some((op) => op.action === action && op.state === 'succeeded');
|
|
359
|
+
if (['merged', 'deployed', 'tracker-updated', 'tracker-status-confirmed'].includes(value.stage)) ensure(success('merge'));
|
|
360
|
+
if (value.stage === 'branch-published') ensure(success('branch-publish'));
|
|
361
|
+
if (value.stage === 'deployed') ensure(success('deploy'));
|
|
362
|
+
if (value.stage === 'tracker-status-confirmed') ensure(success('tracker-transition'));
|
|
363
|
+
if (value.stage === 'tracker-updated') ensure(success('tracker-update'));
|
|
364
|
+
if (value.stage === 'merge-approved')
|
|
365
|
+
ensure(
|
|
366
|
+
value.operations.some(
|
|
367
|
+
(op) => op.action === 'merge' && ['dispatching', 'indeterminate', 'not-applied'].includes(op.state)
|
|
368
|
+
)
|
|
369
|
+
);
|
|
370
|
+
if (value.stage === 'review-requested')
|
|
371
|
+
ensure((value.observation?.review !== null && value.observation !== null) || success('review-request'));
|
|
372
|
+
ensure(
|
|
373
|
+
new Set(value.operations.map((op) => op.digest)).size === value.operations.length &&
|
|
374
|
+
new Set(value.operations.map((op) => op.approvalId)).size === value.operations.length &&
|
|
375
|
+
value.usedApprovalIds.length === value.operations.length
|
|
376
|
+
);
|
|
377
|
+
if (value.stage === 'checks-passed') ensure(ready(value.observation));
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createProviderHttpClient } from '../adapters/http.js';
|
|
2
|
+
import { captureRecord, createProviderWireBody } from '../adapters/contract.js';
|
|
3
|
+
import { parseRepositoryRemote } from '../repositories/identity.js';
|
|
4
|
+
import { createTrustedDeliveryExecutor } from './service.js';
|
|
5
|
+
import { digest, ensure, exact, factsDigest, hash, id, plain, sha, timestamp, validateCandidate, validateReceipt } from './contract.js';
|
|
6
|
+
|
|
7
|
+
export function deploymentPayload(config) {
|
|
8
|
+
const value = plain(config);
|
|
9
|
+
exact(value, ['providerId', 'workflow', 'environment', 'productionEnvironment']);
|
|
10
|
+
id(value.providerId);
|
|
11
|
+
ensure(typeof value.workflow === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}\.ya?ml$/.test(value.workflow));
|
|
12
|
+
ensure(typeof value.environment === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(value.environment));
|
|
13
|
+
ensure(typeof value.productionEnvironment === 'boolean');
|
|
14
|
+
return plain({ workflow: value.workflow, environment: value.environment, productionEnvironment: value.productionEnvironment });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The configured project workflow is trusted to perform deployment and verify its
|
|
18
|
+
// result. GitHub run success proves that workflow's result, not generic app health.
|
|
19
|
+
export function createGithubDeploymentExecutor(inputConfig) {
|
|
20
|
+
const input = captureRecord(inputConfig,
|
|
21
|
+
new Set(['repository', 'deployment', 'mergeReceipt', 'transport', 'headers', 'clock', 'timeoutMs']),
|
|
22
|
+
['repository', 'deployment', 'mergeReceipt', 'transport'], 'invalid-config');
|
|
23
|
+
const supplied = plain(input.repository), repository = parseRepositoryRemote(supplied.url);
|
|
24
|
+
ensure(repository.provider === 'github' && hash(repository) === hash(supplied));
|
|
25
|
+
const payload = deploymentPayload(input.deployment), merged = plain(input.mergeReceipt);
|
|
26
|
+
validateReceipt(merged, {action: 'merge', digest: digest(merged.operationDigest), candidate: {headSha: sha(merged.headSha)}});
|
|
27
|
+
const prPrefix = `${repository.url}/pull/`;
|
|
28
|
+
ensure(merged.resourceUrl.startsWith(prPrefix));
|
|
29
|
+
const pullNumber = Number(merged.resourceUrl.slice(prPrefix.length));
|
|
30
|
+
ensure(Number.isSafeInteger(pullNumber) && pullNumber > 0 && pullNumber <= 1_000_000_000
|
|
31
|
+
&& merged.resourceUrl === `${prPrefix}${pullNumber}`);
|
|
32
|
+
const clock = input.clock ?? (() => new Date().toISOString());
|
|
33
|
+
ensure(typeof clock === 'function');
|
|
34
|
+
const http = createProviderHttpClient({provider: 'github', baseUrl: 'https://api.github.com', transport: input.transport,
|
|
35
|
+
...(input.headers === undefined ? {} : {headers: input.headers}),
|
|
36
|
+
...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs})});
|
|
37
|
+
const root = `/repos/${repository.fullName}`;
|
|
38
|
+
const get = async path => (await http.request({method: 'GET', path: root + path})).data;
|
|
39
|
+
const pages = path => http.paginate({path: root + path, identityKey: 'id'});
|
|
40
|
+
function target(candidate) {
|
|
41
|
+
validateCandidate(candidate);
|
|
42
|
+
ensure(hash(candidate.repository) === hash(repository) && candidate.headSha === merged.headSha, 'changed-facts');
|
|
43
|
+
ensure(candidate.reviewNumber === null || candidate.reviewNumber === pullNumber, 'changed-facts');
|
|
44
|
+
}
|
|
45
|
+
async function workflow() {
|
|
46
|
+
const value = await get(`/actions/workflows/${encodeURIComponent(payload.workflow)}`);
|
|
47
|
+
ensure(Number.isSafeInteger(value?.id) && value.id > 0 && value.state === 'active'
|
|
48
|
+
&& value.path === `.github/workflows/${payload.workflow}`, 'changed-facts');
|
|
49
|
+
const file = await get(`/contents/.github/workflows/${encodeURIComponent(payload.workflow)}?ref=${merged.commitSha}`);
|
|
50
|
+
ensure(file?.type === 'file' && file.path === value.path, 'changed-facts');
|
|
51
|
+
return {id: value.id, path: value.path, blobSha: sha(file.sha), state: value.state};
|
|
52
|
+
}
|
|
53
|
+
async function observe(candidate) {
|
|
54
|
+
target(candidate);
|
|
55
|
+
const pull = await get(`/pulls/${pullNumber}`);
|
|
56
|
+
ensure(pull?.number === pullNumber && pull.state === 'closed' && pull.merged === true
|
|
57
|
+
&& pull.merge_commit_sha === merged.commitSha && pull.html_url === merged.resourceUrl
|
|
58
|
+
&& pull.head?.sha === candidate.headSha && pull.head?.ref === candidate.sourceBranch
|
|
59
|
+
&& pull.head?.repo?.full_name === repository.fullName && pull.base?.ref === candidate.targetBranch
|
|
60
|
+
&& pull.base?.repo?.full_name === repository.fullName, 'changed-facts');
|
|
61
|
+
const configured = await workflow();
|
|
62
|
+
const evidence = hash({workflow: configured, payload, mergeReceipt: merged});
|
|
63
|
+
return plain({repositoryUrl: repository.url, sourceBranch: candidate.sourceBranch, targetBranch: candidate.targetBranch,
|
|
64
|
+
headSha: candidate.headSha, baseSha: sha(pull.base.sha),
|
|
65
|
+
review: {number: pullNumber, state: 'merged', url: merged.resourceUrl, headSha: candidate.headSha},
|
|
66
|
+
checks: {headSha: candidate.headSha, policy: 'unknown', satisfied: null, evidenceDigest: evidence},
|
|
67
|
+
reviews: {headSha: candidate.headSha, policy: 'unknown', satisfied: null, evidenceDigest: evidence},
|
|
68
|
+
observedAt: timestamp(clock())});
|
|
69
|
+
}
|
|
70
|
+
function operation(input) {
|
|
71
|
+
const op = plain(input);
|
|
72
|
+
target(op.candidate);
|
|
73
|
+
digest(op.digest);
|
|
74
|
+
ensure(op.action === 'deploy' && hash(op.payload) === hash(payload) && hash(op.mergeReceipt) === hash(merged), 'changed-facts');
|
|
75
|
+
return op;
|
|
76
|
+
}
|
|
77
|
+
async function reconcile(input) {
|
|
78
|
+
try {
|
|
79
|
+
const op = operation(input), configured = await workflow();
|
|
80
|
+
const matches = (await pages(`/deployments?sha=${merged.commitSha}&task=rivet-deploy&environment=${encodeURIComponent(payload.environment)}&per_page=100`))
|
|
81
|
+
.filter(item => item?.payload?.rivetOperation === op.digest);
|
|
82
|
+
ensure(matches.length === 1, 'unverified-effect');
|
|
83
|
+
const deployment = matches[0];
|
|
84
|
+
ensure(Number.isSafeInteger(deployment.id) && deployment.id > 0 && deployment.sha === merged.commitSha
|
|
85
|
+
&& deployment.ref === merged.commitSha && deployment.task === 'rivet-deploy'
|
|
86
|
+
&& deployment.environment === payload.environment && deployment.production_environment === payload.productionEnvironment
|
|
87
|
+
&& deployment.transient_environment === false && deployment.repository_url === `https://api.github.com${root}`
|
|
88
|
+
&& deployment.payload.workflow === payload.workflow, 'unverified-effect');
|
|
89
|
+
exact(deployment.payload, ['rivetOperation', 'workflow']);
|
|
90
|
+
const statuses = await pages(`/deployments/${deployment.id}/statuses?per_page=100`);
|
|
91
|
+
ensure(statuses.length > 0 && statuses.every(item => Number.isSafeInteger(item.id) && item.id > 0), 'unverified-effect');
|
|
92
|
+
const latest = statuses.reduce((a,b) => a.id > b.id ? a : b);
|
|
93
|
+
ensure(latest.state === 'success' && latest.environment === payload.environment, 'unverified-effect');
|
|
94
|
+
const runPrefix = `${repository.url}/actions/runs/`, runId = Number(String(latest.log_url).slice(runPrefix.length));
|
|
95
|
+
ensure(Number.isSafeInteger(runId) && runId > 0 && latest.log_url === `${runPrefix}${runId}`, 'unverified-effect');
|
|
96
|
+
const run = await get(`/actions/runs/${runId}`);
|
|
97
|
+
ensure(run?.id === runId && run.status === 'completed' && run.conclusion === 'success'
|
|
98
|
+
&& run.head_sha === merged.commitSha && run.workflow_id === configured.id && run.event === 'deployment'
|
|
99
|
+
&& run.repository?.full_name === repository.fullName && run.display_title === `rivet-deploy:${op.digest}`
|
|
100
|
+
&& run.html_url === latest.log_url, 'unverified-effect');
|
|
101
|
+
return {status: 'succeeded', receipt: plain({status: 'succeeded', operationDigest: op.digest,
|
|
102
|
+
headSha: op.candidate.headSha, commitSha: merged.commitSha, resourceUrl: run.html_url,
|
|
103
|
+
evidenceDigest: hash({deployment, latest, run, workflow: configured})})};
|
|
104
|
+
} catch {
|
|
105
|
+
return {status: 'unknown'};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function dispatch(input, context) {
|
|
109
|
+
const op = operation(input), ctx = plain(context);
|
|
110
|
+
exact(ctx, ['deadline']);
|
|
111
|
+
const deadline = Date.parse(timestamp(ctx.deadline));
|
|
112
|
+
const timely = () => ensure(Date.parse(timestamp(clock())) < deadline, 'dispatch-expired');
|
|
113
|
+
timely();
|
|
114
|
+
ensure(factsDigest(await observe(op.candidate)) === op.factsDigest, 'changed-facts');
|
|
115
|
+
const wireBody = createProviderWireBody({provider: 'github', action: 'deploy', resourceId: repository.fullName,
|
|
116
|
+
expectedState: 'merged', expectedVersion: merged.commitSha, idempotencyKey: op.digest,
|
|
117
|
+
payload: {ref: merged.commitSha, auto_merge: false, task: 'rivet-deploy', environment: payload.environment,
|
|
118
|
+
production_environment: payload.productionEnvironment, transient_environment: false,
|
|
119
|
+
payload: {rivetOperation: op.digest, workflow: payload.workflow}}});
|
|
120
|
+
timely();
|
|
121
|
+
const controller = new AbortController(), remaining = deadline - Date.parse(timestamp(clock()));
|
|
122
|
+
ensure(remaining > 0, 'dispatch-expired');
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), remaining);
|
|
124
|
+
try {
|
|
125
|
+
await http.request({method: 'POST', path: `${root}/deployments`, wireBody, signal: controller.signal});
|
|
126
|
+
} finally { clearTimeout(timer); }
|
|
127
|
+
const result = await reconcile(op);
|
|
128
|
+
ensure(result.status === 'succeeded', 'unverified-effect');
|
|
129
|
+
return result.receipt;
|
|
130
|
+
}
|
|
131
|
+
return createTrustedDeliveryExecutor({provider:'github',capabilities:[{action:'deploy',conditionalHead:true,reconcile:true}],observe,dispatch,reconcile});
|
|
132
|
+
}
|