From edeb3f3a41026eaf3430f418036130badd8be997 Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 22 Sep 2026 15:42:28 +0200 Subject: [PATCH] fix(flows): pin released Garden and Babysitter artifacts Session-Id: 01a0c4a6-dd65-7ce1-a90e-de1b0b4e86c3 Session-Id: 01a0c4a6-dd65-7ce1-a90e-de1b0b4e86c3 --- web/content/docs/relayflows/plugins.mdx | 30 +++++++++---------- web/content/docs/relayflows/recommended.mdx | 6 ++-- web/data/flow-plugin-catalog.v1.json | 15 +++++----- web/data/recommended-flow-catalog.v1.json | 14 ++++----- web/lib/flow-local.ts | 4 +-- web/lib/flow-plugin-catalog.ts | 5 ++-- web/lib/test/flow-local.test.ts | 4 ++- web/lib/test/flow-plugin-catalog.test.ts | 29 +++++++++++------- web/lib/test/recommended-flow-catalog.test.ts | 16 +++++----- .../test/recommended-flow-contract.test.ts | 28 +++++++++++++++++ web/scripts/recommended-flow-contract.d.mts | 6 ++++ web/scripts/recommended-flow-contract.mjs | 17 +++++++++++ .../verify-recommended-flow-catalog.mjs | 8 +++-- 13 files changed, 124 insertions(+), 58 deletions(-) create mode 100644 web/lib/test/recommended-flow-contract.test.ts create mode 100644 web/scripts/recommended-flow-contract.d.mts create mode 100644 web/scripts/recommended-flow-contract.mjs diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx index a352c8c..80815e8 100644 --- a/web/content/docs/relayflows/plugins.mdx +++ b/web/content/docs/relayflows/plugins.mdx @@ -32,29 +32,29 @@ Worked example — Babysitter on Software Garden: "schema": 2, "kind": "flow-extension", "name": "babysitter", - "version": "0.1.0", - "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate.", + "version": "0.2.0", + "description": "Native Babysitter for Software Factory: turns a verified PR delivery into one cloud:babysitter-turn request.", "compat": { - "surface": "^2.0.22", - "sdk": "^2.0.22", + "surface": "^2.0.26", + "sdk": "^2.0.26", "base": [{ "name": "software-factory", "version": "^2.0.0" }] }, "entry": "babysitter.flow.ts", - "extends": { "handlers": true, "hooks": ["merge-gate"], "verbs": [], "gates": [] }, + "extends": { "handlers": true, "hooks": [] }, "triggers": [ - { "provider": "github", "event": "pull_request", "actions": ["opened", "synchronize", "reopened", "ready_for_review"] }, - { "provider": "github", "event": "pull_request_review", "actions": ["submitted"] }, + { "provider": "github", "event": "pull_request", "actions": ["opened", "synchronize", "reopened", "ready_for_review", "closed", "labeled", "unlabeled"] }, + { "provider": "github", "event": "pull_request_review", "actions": ["submitted", "dismissed"] }, { "provider": "github", "event": "check_run", "actions": ["completed"] }, { "provider": "github", "event": "issue_comment", "actions": ["created"] } ], "permissions": { "integrations": ["github"], - "harnesses": ["claude"], + "harnesses": ["codex"], "mcp": [], - "writes": ["github:pull_request:comment"], - "budget": { "dollars": 8, "wallclock": "45m" } + "writes": ["cloud:babysitter-turn"], + "budget": { "dollars": 1, "wallclock": "5m" } }, - "preflight": { "credentials": [], "servers": ["https://api.github.com"] } + "preflight": { "credentials": [], "servers": [] } } ``` @@ -67,7 +67,7 @@ Rules that matter at review time: - Manifest triggers are validated against the surface registry `providerEventTypes`. An event the registry cannot route is `plugin_event_unroutable`. - GitHub `pull_request.ready_for_review`, `pull_request.labeled`, and `pull_request.unlabeled` are not in the surface registry yet. A Babysitter manifest that declares them is refused `plugin_event_unroutable` until the relayfile adapter catalog grows. The gallery states this fail-closed; it does not paper over it. + Relayflows 2.0.26 carries all eleven Babysitter event identities, but the authored executor still refuses a matching handler before either body starts because schema-2 permissions are not isolated there. Cloud may enable Babysitter only through the separately authorized existing-session lineage path; an arbitrary labelled pull request is not execution authority. ## `flows add` @@ -118,7 +118,7 @@ The gallery and the deploy wizard display a tier. **The label is never used to s | **verified** | bundle `identity.json` keyid matches a publisher key registered in Cloud | | **community** | anything else | -Babysitter in catalog v1 is **community**: the owner is AgentWorkforce, but the pinned sha is not reachable from `main` until the babysitter branch merges. That is the honest label, not a downgrade of the code. +Babysitter in catalog v2 is **first-party**: its `v2.0.26` commit is reachable from `AgentWorkforce/flows` main. The label does not widen runtime authority. `permissions.writes` stays a reviewed declaration labelled UNENFORCED until gate 8. Displaying a first-party or verified badge does not enforce write scope, skip the digest, or route an event the registry does not carry. @@ -129,9 +129,9 @@ Babysitter in catalog v1 is **community**: the owner is AgentWorkforce, but the `flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive: ```markdown -[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fb4dd665eb433bd7f52d1045543aef5f14fb7891e%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F05c3dff138883322e80cb793b1f5a097ad510572%2Fexamples%2Fbabysitter) +[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F8b33ebab8347514f80d9da5a81206a087f641714%2Fextensions%2Fbabysitter) ``` The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge. -The catalog itself is versioned JSON, `{version:1, plugins:[{name, description, source:{owner,repo,path}, ref, digest, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate. +The catalog itself is versioned JSON, `{version:2, plugins:[{name, description, source:{owner,repo,path}, ref, digest, manifestSha256, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate. diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx index 01e813d..2ff73c6 100644 --- a/web/content/docs/relayflows/recommended.mdx +++ b/web/content/docs/relayflows/recommended.mdx @@ -9,7 +9,7 @@ Recommended flows are maintained starting points. The catalog is public, version Software Garden is the display name of the first recommended flow. Its stable catalog and authored flow ID is `software-factory`: a GitHub issue starts implementation, deterministic repository checks, adversarial review, and a pull request for a human decision. -The released source uses Claude Code for implementation and review, so catalog version 1 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow. +The released source uses Claude Code for implementation and review, so catalog version 2 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow. Software Garden currently supports GitHub repositories. Each activation supplies one or more repositories, and Cloud scopes a GitHub listener to each one. The catalog deliberately does not define Babysitter behavior. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own. @@ -22,12 +22,12 @@ GET https://agentrelay.com/api/v1/flows/catalog GET https://agentrelay.com/api/v1/flows/catalog/software-factory ``` -The list response starts with `schemaVersion: 1` and `catalogVersion: 1`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference. +The list response starts with `schemaVersion: 1` and `catalogVersion: 2`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference. The catalog does not copy or generate the flow body. `source` names the canonical `AgentWorkforce/flows` owner, repository, path, release tag, full commit SHA, GitHub blob and raw URLs, media type, and SHA-256 content digest. Both URLs contain the commit SHA, never a mutable branch: ```text -https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts +https://github.com/AgentWorkforce/flows/blob/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts ``` CI and the production release workflow resolve the release tag, fetch the pinned raw file with a size bound, and verify the digest. A broken, moved, mutable, or drifted source therefore fails before the catalog can ship. Cloud fetches it through the existing trusted source loader and stores the fetched body only as that deployment's immutable snapshot and provenance. diff --git a/web/data/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json index d7323b9..e163ad7 100644 --- a/web/data/flow-plugin-catalog.v1.json +++ b/web/data/flow-plugin-catalog.v1.json @@ -1,14 +1,15 @@ { - "version": 1, + "version": 2, "plugins": [ { "name": "babysitter", - "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate. Fail-closed: GitHub pull_request.ready_for_review, labeled, and unlabeled are not in the surface registry, so a manifest that declares them is refused plugin_event_unroutable until the relayfile adapter catalog grows.", - "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "examples/babysitter" }, - "ref": "05c3dff138883322e80cb793b1f5a097ad510572", - "digest": "ae6af3335eb6d4e54559327acc1465419244b47911d8ff356850b61f6228d862", - "compat": { "surface": "^2.0.22", "sdk": "^2.0.22", "base": ["software-factory"] }, - "tier": "community", + "description": "Native Babysitter for Software Garden: eleven GitHub wake events request one authorized existing-session turn. Cloud rechecks the live babysit label, pull-request head, and session binding; the extension holds no GitHub write or merge authority. Handler execution remains fail-closed until the Cloud capability adapter and native delivery path ship.", + "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "extensions/babysitter" }, + "ref": "8b33ebab8347514f80d9da5a81206a087f641714", + "digest": "bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee", + "manifestSha256": "5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226", + "compat": { "surface": "^2.0.26", "sdk": "^2.0.26", "base": ["software-factory"] }, + "tier": "first-party", "base": ["software-factory"] } ] diff --git a/web/data/recommended-flow-catalog.v1.json b/web/data/recommended-flow-catalog.v1.json index 052dbe7..08fb7d1 100644 --- a/web/data/recommended-flow-catalog.v1.json +++ b/web/data/recommended-flow-catalog.v1.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "catalogVersion": 1, + "catalogVersion": 2, "flows": [ { "id": "software-factory", - "version": 1, + "version": 2, "name": "Software Garden", "summary": "Turn GitHub issues into reviewed, tested pull requests.", "description": "Implement a ticket, run the repository checks outside the agent, put the change through adversarial review, and open a pull request for a human decision.", @@ -26,12 +26,12 @@ "owner": "AgentWorkforce", "repo": "flows", "path": "examples/software-factory/software-factory.flow.ts", - "release": "v2.0.22", - "ref": "b4dd665eb433bd7f52d1045543aef5f14fb7891e", - "url": "https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts", - "rawUrl": "https://raw.githubusercontent.com/AgentWorkforce/flows/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts", + "release": "v2.0.26", + "ref": "8b33ebab8347514f80d9da5a81206a087f641714", + "url": "https://github.com/AgentWorkforce/flows/blob/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts", + "rawUrl": "https://raw.githubusercontent.com/AgentWorkforce/flows/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts", "mediaType": "text/typescript", - "sha256": "41c2137179455881a0fcf568006d2b41e24386ba1cf7dc2c6051871b6f744368" + "sha256": "49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6" } } ] diff --git a/web/lib/flow-local.ts b/web/lib/flow-local.ts index ef1f841..ca25fd8 100644 --- a/web/lib/flow-local.ts +++ b/web/lib/flow-local.ts @@ -52,12 +52,12 @@ import { workflowAgents } from './flow-workflows'; * Cloud, which must render a `declined` run before any flow emits one. Switch * the two guards together with that Cloud change, not as part of a pin bump. * - * 2.0.22 is the current release. Beyond the above it carries + * 2.0.26 is the current release. Beyond the above it carries * AgentWorkforce/flows#484: a Claude agent step completes on Claude's own * result rather than on process exit, so an agent that started a background * task no longer holds the run open until its wall-clock limit. */ -export const RELAYFLOWS_VERSION = '2.0.22'; +export const RELAYFLOWS_VERSION = '2.0.26'; export const LOCAL_PREFLIGHT = 'relay-preflight.mjs'; export const LOCAL_INSTALL = `npm install --save-dev relayflows@${RELAYFLOWS_VERSION} @relayflows/surface@${RELAYFLOWS_VERSION}`; diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts index f60f0e5..e1e4654 100644 --- a/web/lib/flow-plugin-catalog.ts +++ b/web/lib/flow-plugin-catalog.ts @@ -10,13 +10,14 @@ export type FlowPluginCatalogEntry = { source: { owner: string; repo: string; path: string }; ref: string; digest: string; + manifestSha256: string; compat: { surface: string; sdk: string; base: string[] }; tier: FlowPluginTrustTier; base: string[]; }; export type FlowPluginCatalog = { - version: 1; + version: 2; plugins: FlowPluginCatalogEntry[]; }; @@ -43,7 +44,7 @@ export const FLOW_PLUGIN_TRUST_TIER_DISCLAIMER = 'Tiers are displayed only. They never skip digest, compat, or event-routability checks.'; export const SOFTWARE_FACTORY_FLOW_URL = - 'https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts'; + 'https://github.com/AgentWorkforce/flows/blob/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts'; export const BASE_FLOW_URLS: Record = { 'software-factory': SOFTWARE_FACTORY_FLOW_URL, diff --git a/web/lib/test/flow-local.test.ts b/web/lib/test/flow-local.test.ts index e7670ff..78197b0 100644 --- a/web/lib/test/flow-local.test.ts +++ b/web/lib/test/flow-local.test.ts @@ -93,7 +93,9 @@ describe('local flow starter kit', () => { // started can no longer hold the run open until its wall-clock limit. expect(LOCAL_INSTALL).toContain(`relayflows@${RELAYFLOWS_VERSION}`); expect(LOCAL_INSTALL).toContain(`@relayflows/surface@${RELAYFLOWS_VERSION}`); - expect(RELAYFLOWS_VERSION).toBe('2.0.22'); + // 2.0.26 adds the full Babysitter GitHub trigger vocabulary and keeps the + // local starter on the same released Surface/SDK graph as the catalog. + expect(RELAYFLOWS_VERSION).toBe('2.0.26'); for (const workflow of ['traditional', 'prototype', 'simple'] as const) { const source = factorySource({ ...draft, workflow }, 'local'); const code = withoutComments(source); diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts index 46ef017..68a52b0 100644 --- a/web/lib/test/flow-plugin-catalog.test.ts +++ b/web/lib/test/flow-plugin-catalog.test.ts @@ -21,8 +21,8 @@ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; describe('flow plugin catalog', () => { const catalog = getFlowPluginCatalog(); - it('is version 1 with unique kebab-case plugin names', () => { - expect(catalog.version).toBe(1); + it('is version 2 with unique kebab-case plugin names', () => { + expect(catalog.version).toBe(2); expect(Array.isArray(catalog.plugins)).toBe(true); expect(catalog.plugins.length).toBeGreaterThan(0); const names = catalog.plugins.map((plugin) => plugin.name); @@ -30,34 +30,41 @@ describe('flow plugin catalog', () => { expect(new Set(names).size).toBe(names.length); }); - it('records a fail-closed babysitter entry with a pinned sha and digest', () => { + it('records the released Babysitter artifact without claiming handler execution', () => { const babysitter = getFlowPlugin('babysitter'); expect(babysitter).toMatchObject({ - source: { owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter' }, - tier: 'community', + source: { owner: 'AgentWorkforce', repo: 'flows', path: 'extensions/babysitter' }, + ref: '8b33ebab8347514f80d9da5a81206a087f641714', + digest: 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee', + manifestSha256: '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226', + compat: { surface: '^2.0.26', sdk: '^2.0.26', base: ['software-factory'] }, + tier: 'first-party', base: ['software-factory'], }); expect(babysitter!.ref).toMatch(SHA); expect(babysitter!.digest).toMatch(HEX64); - expect(babysitter!.description).toContain('plugin_event_unroutable'); + expect(babysitter!.manifestSha256).toMatch(HEX64); + expect(babysitter!.description).not.toContain('plugin_event_unroutable'); + expect(babysitter!.description).toContain('fail-closed'); + expect(babysitter!.description).toContain('no GitHub write or merge authority'); expect(FLOW_PLUGIN_TRUST_TIERS.includes(babysitter!.tier)).toBe(true); - expect(pluginHasUnroutableTriggers(babysitter!)).toBe(true); + expect(pluginHasUnroutableTriggers(babysitter!)).toBe(false); }); it('builds a GitHub tree URL at the pinned sha, not a branch', () => { const babysitter = getFlowPlugin('babysitter')!; expect(flowPluginSourceUrl(babysitter)).toBe( - `https://github.com/AgentWorkforce/flows/tree/${babysitter.ref}/examples/babysitter`, + `https://github.com/AgentWorkforce/flows/tree/${babysitter.ref}/extensions/babysitter`, ); expect(flowPluginGithubRef(babysitter)).toBe( - `github:AgentWorkforce/flows@${babysitter.ref}#examples/babysitter`, + `github:AgentWorkforce/flows@${babysitter.ref}#extensions/babysitter`, ); }); }); describe('flowPluginBadgeMarkdown', () => { const pluginA = - 'https://github.com/AgentWorkforce/flows/tree/05c3dff138883322e80cb793b1f5a097ad510572/examples/babysitter'; + 'https://github.com/AgentWorkforce/flows/tree/8b33ebab8347514f80d9da5a81206a087f641714/extensions/babysitter'; const pluginB = 'github:acme/plugins@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#extra'; it('points the Install plugin badge at /cloud/flows/deploy with flow and plugin', () => { @@ -74,7 +81,7 @@ describe('flowPluginBadgeMarkdown', () => { const url = new URL(href!); expect(url.pathname).toBe('/cloud/flows/deploy'); expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); - expect(SOFTWARE_FACTORY_FLOW_URL).toContain('/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/'); + expect(SOFTWARE_FACTORY_FLOW_URL).toContain('/blob/8b33ebab8347514f80d9da5a81206a087f641714/'); expect(SOFTWARE_FACTORY_FLOW_URL).not.toContain('/blob/main/'); expect(url.searchParams.getAll('plugin')).toEqual([pluginA]); }); diff --git a/web/lib/test/recommended-flow-catalog.test.ts b/web/lib/test/recommended-flow-catalog.test.ts index cad483d..6aadea3 100644 --- a/web/lib/test/recommended-flow-catalog.test.ts +++ b/web/lib/test/recommended-flow-catalog.test.ts @@ -11,10 +11,10 @@ describe('recommended flow catalog', () => { const catalog = getRecommendedFlowCatalog(); expect(catalog).toEqual({ schemaVersion: 1, - catalogVersion: 1, + catalogVersion: 2, flows: [{ id: 'software-factory', - version: 1, + version: 2, name: 'Software Garden', summary: expect.any(String), description: expect.any(String), @@ -31,12 +31,12 @@ describe('recommended flow catalog', () => { owner: 'AgentWorkforce', repo: 'flows', path: 'examples/software-factory/software-factory.flow.ts', - release: 'v2.0.22', - ref: 'b4dd665eb433bd7f52d1045543aef5f14fb7891e', - url: 'https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts', - rawUrl: 'https://raw.githubusercontent.com/AgentWorkforce/flows/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts', + release: 'v2.0.26', + ref: '8b33ebab8347514f80d9da5a81206a087f641714', + url: 'https://github.com/AgentWorkforce/flows/blob/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts', + rawUrl: 'https://raw.githubusercontent.com/AgentWorkforce/flows/8b33ebab8347514f80d9da5a81206a087f641714/examples/software-factory/software-factory.flow.ts', mediaType: 'text/typescript', - sha256: '41c2137179455881a0fcf568006d2b41e24386ba1cf7dc2c6051871b6f744368', + sha256: '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6', }, }], }); @@ -75,7 +75,7 @@ describe('recommended flow catalog HTTP surface', () => { await expect(detail.json()).resolves.toMatchObject({ id: 'software-factory', name: 'Software Garden', - source: { ref: 'b4dd665eb433bd7f52d1045543aef5f14fb7891e' }, + source: { ref: '8b33ebab8347514f80d9da5a81206a087f641714' }, }); }); diff --git a/web/lib/test/recommended-flow-contract.test.ts b/web/lib/test/recommended-flow-contract.test.ts new file mode 100644 index 0000000..795c728 --- /dev/null +++ b/web/lib/test/recommended-flow-contract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + SOFTWARE_FACTORY_METADATA_SNIPPETS, + assertRecommendedFlowSourceContract, +} from '../../scripts/recommended-flow-contract.mjs'; + +const flow = { id: 'software-factory' }; +const validSource = SOFTWARE_FACTORY_METADATA_SNIPPETS.join('\n'); + +describe('recommended Software Garden source contract', () => { + it('accepts a source carrying the ticket title and exact closing-reference guards', () => { + expect(() => assertRecommendedFlowSourceContract(flow, validSource)).not.toThrow(); + }); + + it.each(SOFTWARE_FACTORY_METADATA_SNIPPETS.map(snippet => [snippet] as const))( + 'rejects the pinned source when it drops %s', + (removed: string) => { + const source = SOFTWARE_FACTORY_METADATA_SNIPPETS.filter((snippet: string) => snippet !== removed).join('\n'); + expect(() => assertRecommendedFlowSourceContract(flow, source)).toThrow( + /ticket-derived title\/exact closing-reference contract/, + ); + }, + ); + + it('does not impose the factory contract on another catalog flow', () => { + expect(() => assertRecommendedFlowSourceContract({ id: 'other' }, '')).not.toThrow(); + }); +}); diff --git a/web/scripts/recommended-flow-contract.d.mts b/web/scripts/recommended-flow-contract.d.mts new file mode 100644 index 0000000..a7617ed --- /dev/null +++ b/web/scripts/recommended-flow-contract.d.mts @@ -0,0 +1,6 @@ +export const SOFTWARE_FACTORY_METADATA_SNIPPETS: readonly string[]; + +export function assertRecommendedFlowSourceContract( + flow: { id: string }, + sourceText: string, +): void; diff --git a/web/scripts/recommended-flow-contract.mjs b/web/scripts/recommended-flow-contract.mjs new file mode 100644 index 0000000..25f9fd1 --- /dev/null +++ b/web/scripts/recommended-flow-contract.mjs @@ -0,0 +1,17 @@ +export const SOFTWARE_FACTORY_METADATA_SNIPPETS = Object.freeze([ + 'const normalizedTitle = issue.title.trim().replace(/\\s+/g, " ");', + 'const title = Array.from(normalizedTitle).slice(0, 240).join("").trim();', + '? `Fixes ${issueIdentifier}`', + 'expected="Fixes $identifier"', + 'count=$(grep -xcF "$expected"', + 'elif [ "$count" -ne 1 ]', + '--title ${shellWord(title)} --body-file ${WORK}/pr-body.md', +]); + +export function assertRecommendedFlowSourceContract(flow, sourceText) { + if (flow.id !== 'software-factory') return; + const missing = SOFTWARE_FACTORY_METADATA_SNIPPETS.filter(snippet => !sourceText.includes(snippet)); + if (missing.length > 0) { + throw new Error(`${flow.id}: released source lacks the ticket-derived title/exact closing-reference contract (${missing.join(', ')})`); + } +} diff --git a/web/scripts/verify-recommended-flow-catalog.mjs b/web/scripts/verify-recommended-flow-catalog.mjs index 51fa1db..15246f7 100644 --- a/web/scripts/verify-recommended-flow-catalog.mjs +++ b/web/scripts/verify-recommended-flow-catalog.mjs @@ -2,14 +2,15 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; +import { assertRecommendedFlowSourceContract } from './recommended-flow-contract.mjs'; const execFileAsync = promisify(execFile); const catalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.meta.url); const catalog = JSON.parse(await readFile(catalogUrl, 'utf8')); const MAX_SOURCE_BYTES = 1024 * 1024; -if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 1 || !Array.isArray(catalog.flows)) { - throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 1, with a flows array'); +if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 2 || !Array.isArray(catalog.flows)) { + throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 2, with a flows array'); } for (const flow of catalog.flows) { @@ -47,6 +48,7 @@ for (const flow of catalog.flows) { } if (!response.body) throw new Error(`${flow.id}: source response has no body`); const hash = createHash('sha256'); + const chunks = []; const reader = response.body.getReader(); let received = 0; while (true) { @@ -58,10 +60,12 @@ for (const flow of catalog.flows) { throw new Error(`${flow.id}: source exceeds ${MAX_SOURCE_BYTES} bytes`); } hash.update(value); + chunks.push(Buffer.from(value)); } const actualHash = hash.digest('hex'); if (actualHash !== source.sha256) { throw new Error(`${flow.id}: source hash is ${actualHash}, expected ${source.sha256}`); } + assertRecommendedFlowSourceContract(flow, Buffer.concat(chunks).toString('utf8')); console.log(`${flow.id}: verified ${source.release} (${source.ref}) sha256:${actualHash}`); }