@kungfu-tech/buildchain 4.1.2 → 4.1.3-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.
Files changed (61) hide show
  1. package/CONTRIBUTING.md +7 -4
  2. package/architecture/action-taxonomy.json +4 -0
  3. package/architecture/agent-change-map.md +40 -0
  4. package/architecture/internal-capabilities.json +49 -0
  5. package/architecture/maintainability-debt.json +2 -1
  6. package/architecture/maintainability-policy.json +4 -4
  7. package/architecture/release-topology.json +11 -1
  8. package/architecture/universal-workflow-bootstrap.json +13 -2
  9. package/architecture/universal-workflow-capability-policy.json +15 -9
  10. package/contracts/promotion-invocation-v1.schema.json +6 -3
  11. package/contracts/promotion-request-v1.schema.json +6 -3
  12. package/contracts/release-discussion-v1.schema.json +140 -0
  13. package/dist/readers/release-discussion.cjs +203 -0
  14. package/dist/site/buildchain-contract.json +15 -11
  15. package/dist/site/buildchain-site.json +57 -6
  16. package/dist/site/capability-registry.json +3 -3
  17. package/dist/site/kfd-claims.json +95 -9
  18. package/dist/site/kfd-upstream-aggregate.json +1 -1
  19. package/dist/site/manual-registry.json +1 -1
  20. package/dist/site/node-api-registry.json +83 -1
  21. package/dist/site/page-registry.json +54 -3
  22. package/dist/site/public-surface-audit.json +53 -7
  23. package/dist/site/publication-registry.json +2 -2
  24. package/dist/site/release-provenance.json +2 -1
  25. package/dist/site/site-manifest.json +3 -3
  26. package/dist/site/workflow-registry.json +79 -7
  27. package/docs/node-api-reference.md +9 -0
  28. package/docs/release-discussions.md +154 -0
  29. package/package.json +10 -6
  30. package/packages/core/providers/github/discussions/materials.js +146 -0
  31. package/packages/core/providers/github/discussions/transport.js +115 -0
  32. package/packages/core/publication/binary/action.js +21 -7
  33. package/packages/core/release/discussion/actions.js +65 -0
  34. package/packages/core/release/discussion/binary.js +92 -0
  35. package/packages/core/release/discussion/checkpoints.js +241 -0
  36. package/packages/core/release/discussion/envelope.js +141 -0
  37. package/packages/core/release/discussion/publication.js +121 -0
  38. package/packages/core/release/discussion/qualification.js +121 -0
  39. package/packages/core/release/discussion/reader-entry.js +11 -0
  40. package/packages/core/release/discussion/reader.js +168 -0
  41. package/packages/core/release/discussion/recovery.js +132 -0
  42. package/packages/core/release/discussion/session.js +113 -0
  43. package/packages/core/release/discussion/store.js +169 -0
  44. package/packages/core/release/github-release.js +3 -1
  45. package/packages/core/release/promote-candidate/action.js +86 -6
  46. package/packages/core/release/promote-candidate/preparation.js +106 -0
  47. package/packages/core/release/promote-candidate/product-provider.js +4 -1
  48. package/packages/core/release/promote-candidate/provider-settlement.js +36 -23
  49. package/packages/core/release/promote-candidate/transaction.js +55 -97
  50. package/packages/core/release/promotion/candidate.js +11 -2
  51. package/packages/core/release/promotion/qualification.js +5 -2
  52. package/packages/core/release/promotion-request.js +8 -4
  53. package/packages/core/workflow/engine/execution.js +5 -1
  54. package/packages/core/workflow/engine/provider-context.js +2 -0
  55. package/packages/core/workflow/engine/release-observation.js +1 -0
  56. package/packages/core/workflow/engine/release-promotion.js +38 -26
  57. package/packages/core/workflow/universal-workflow-bootstrap.js +1 -0
  58. package/scripts/build-release-discussion-reader.mjs +34 -0
  59. package/scripts/inventory/binary.mjs +1 -1
  60. package/scripts/maintainability-metrics.mjs +1 -0
  61. package/scripts/site-capability-metadata.mjs +1 -0
@@ -0,0 +1,168 @@
1
+ import {
2
+ ENVELOPE_SCHEMA,
3
+ INTENT_SCHEMA,
4
+ PAYLOAD_SCHEMA,
5
+ decodeRecord,
6
+ recordDigest,
7
+ validateRuntime,
8
+ } from "./envelope.js";
9
+
10
+ // Pure historical reader: no provider access, writes or effect recovery.
11
+ export function readReleaseDiscussion({ body, records }) {
12
+ const intent = decodeRecord(body);
13
+ if (intent?.schema !== INTENT_SCHEMA)
14
+ throw new Error("Missing release Discussion intent");
15
+ if (
16
+ intent.id !==
17
+ recordDigest({ repository: intent.repository, key: intent.key })
18
+ )
19
+ throw new Error("Release intent identity mismatch");
20
+ if (
21
+ !Array.isArray(intent.expectedNodes) ||
22
+ !intent.expectedNodes.length ||
23
+ new Set(intent.expectedNodes).size !== intent.expectedNodes.length
24
+ )
25
+ throw new Error("Missing or duplicate expected nodes");
26
+ const attempts = groupAttempts(validateRecords(intent, records));
27
+ if (!attempts.size)
28
+ return {
29
+ intent,
30
+ status: "pending",
31
+ attempts: [],
32
+ missingNodes: intent.expectedNodes,
33
+ };
34
+ const { head, lineage } = resolveLineage(attempts);
35
+ const nodes = currentNodes(attempts.get(head).events);
36
+ const missingNodes = intent.expectedNodes.filter((node) => !nodes[node]);
37
+ const complete = intent.expectedNodes.every(
38
+ (node) => nodes[node]?.status === "success",
39
+ );
40
+ const failed = Object.values(nodes).some((event) =>
41
+ ["failure", "cancelled"].includes(event.status),
42
+ );
43
+ return {
44
+ intent,
45
+ attempt: head,
46
+ attempts: lineage.reverse(),
47
+ nodes,
48
+ missingNodes,
49
+ status: complete ? "complete" : failed ? "failed" : "running",
50
+ handoff: {
51
+ schema: "buildchain.release-handoff/v1",
52
+ intent: intent.id,
53
+ predecessor: head,
54
+ completedNodes: intent.expectedNodes.filter(
55
+ (node) => nodes[node]?.status === "success",
56
+ ),
57
+ },
58
+ };
59
+ }
60
+
61
+ function validateRecords(intent, records) {
62
+ const events = new Map();
63
+ for (const raw of records) {
64
+ const event = typeof raw === "string" ? decodeRecord(raw) : raw;
65
+ if (!event) continue;
66
+ validateRuntime(event.runtime);
67
+ if (
68
+ !["progress", "checkpoint"].includes(event.kind) ||
69
+ !event.writer ||
70
+ !event.attempt ||
71
+ typeof event.predecessor !== "string"
72
+ )
73
+ throw new Error("Invalid record ownership");
74
+ const { id, ...content } = event;
75
+ if (
76
+ event.schema !== ENVELOPE_SCHEMA ||
77
+ event.payloadSchema !== PAYLOAD_SCHEMA
78
+ )
79
+ throw new Error("Record requires its historical runtime reader");
80
+ if (event.intent !== intent.id || id !== recordDigest(content))
81
+ throw new Error("Release record identity mismatch");
82
+ if (event.node !== "attempt" && !intent.expectedNodes.includes(event.node))
83
+ throw new Error("Undeclared release node");
84
+ if (
85
+ !["running", "success", "failure", "cancelled"].includes(event.status) ||
86
+ !Number.isSafeInteger(event.sequence) ||
87
+ event.sequence < 0
88
+ )
89
+ throw new Error("Invalid release progress");
90
+ events.set(id, event);
91
+ }
92
+ return events;
93
+ }
94
+
95
+ function groupAttempts(events) {
96
+ const attempts = new Map();
97
+ for (const event of events.values()) {
98
+ const attempt = attempts.get(event.attempt) || {
99
+ predecessor: event.predecessor,
100
+ events: [],
101
+ };
102
+ if (attempt.predecessor !== event.predecessor)
103
+ throw new Error("Conflicting workflow attempt identity");
104
+ attempt.events.push(event);
105
+ attempts.set(event.attempt, attempt);
106
+ }
107
+ return attempts;
108
+ }
109
+
110
+ function resolveLineage(attempts) {
111
+ const predecessors = new Set(
112
+ [...attempts.values()]
113
+ .map((attempt) => attempt.predecessor)
114
+ .filter(Boolean),
115
+ );
116
+ for (const predecessor of predecessors)
117
+ if (!attempts.has(predecessor))
118
+ throw new Error("Recovery predecessor is missing");
119
+ const heads = [...attempts.keys()].filter((id) => !predecessors.has(id));
120
+ if (heads.length !== 1)
121
+ throw new Error("Release attempt succession is ambiguous or cyclic");
122
+ const lineage = [];
123
+ let current = heads[0];
124
+ while (current) {
125
+ if (lineage.includes(current)) throw new Error("Cyclic recovery chain");
126
+ lineage.push(current);
127
+ current = attempts.get(current).predecessor;
128
+ }
129
+ if (lineage.length !== attempts.size)
130
+ throw new Error("Disconnected recovery chain");
131
+ return { head: heads[0], lineage };
132
+ }
133
+
134
+ function currentNodes(events) {
135
+ const nodes = {},
136
+ sequences = new Map();
137
+ // Reuse is explicit and independently qualified by the recovering runtime.
138
+ for (const event of events) {
139
+ if (event.kind === "checkpoint") continue;
140
+ const key = `${event.node}:${event.sequence}`;
141
+ if (sequences.has(key) && sequences.get(key) !== event.id)
142
+ throw new Error("Conflicting node progress at the same sequence");
143
+ sequences.set(key, event.id);
144
+ const old = nodes[event.node];
145
+ if (!old || old.sequence < event.sequence) nodes[event.node] = event;
146
+ }
147
+ return nodes;
148
+ }
149
+
150
+ // Workflow outputs carry a bounded projection, never the complete comment log.
151
+ export function discussionStatus(state) {
152
+ return {
153
+ discussionId: state.discussion.id,
154
+ discussionUrl: state.discussion.url,
155
+ intent: state.intent.id,
156
+ status: state.status,
157
+ attempt: state.attempt || "",
158
+ attempts: state.attempts,
159
+ missingNodes: state.missingNodes,
160
+ handoff: state.handoff || null,
161
+ nodes: Object.fromEntries(
162
+ Object.entries(state.nodes || {}).map(([node, record]) => [
163
+ node,
164
+ { status: record.status, recordId: record.id, writer: record.writer },
165
+ ]),
166
+ ),
167
+ };
168
+ }
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getOctokit } from "@actions/github";
4
+ import { decodeRecord } from "./envelope.js";
5
+ import { releaseDiscussionStore } from "./store.js";
6
+ import { discussionTransport } from "../../providers/github/discussions/transport.js";
7
+ import { releaseCheckpoints, restoreRecoveryMaterials } from "./checkpoints.js";
8
+ import { validateReleaseCandidatePassport } from "../release-candidate.js";
9
+ import {
10
+ assertCandidateEvidenceBinding,
11
+ aggregateReleasePassport,
12
+ observeProtectedPublicationSource,
13
+ } from "../promote-candidate/evidence-binding.js";
14
+
15
+ export async function recoverDiscussionCandidate({
16
+ discussionId,
17
+ repository,
18
+ token,
19
+ outputDir,
20
+ targetSha,
21
+ targetRef,
22
+ }) {
23
+ const octokit = getOctokit(token);
24
+ const transport = discussionTransport(octokit.graphql);
25
+ const discussion = await transport.get(discussionId);
26
+ const intent = decodeRecord(discussion.body);
27
+ if (
28
+ intent?.repository !== repository ||
29
+ discussion.author?.__typename !== "Bot" ||
30
+ discussion.author.login !== "github-actions"
31
+ )
32
+ throw new Error(
33
+ "Recovery requires the consumer workflow's original Discussion",
34
+ );
35
+ const session = { discussion, intent, writerId: discussion.author.id };
36
+ const store = releaseDiscussionStore(transport);
37
+ const observed = await store.read(session);
38
+ const snapshots = observed.records
39
+ .filter(
40
+ (record) =>
41
+ record.kind === "checkpoint" && record.node === "qualification",
42
+ )
43
+ .sort(
44
+ (a, b) =>
45
+ observed.attempts.indexOf(b.attempt) -
46
+ observed.attempts.indexOf(a.attempt) || b.sequence - a.sequence,
47
+ );
48
+ if (!snapshots.length)
49
+ throw new Error("Discussion has no retained qualified recovery material");
50
+ const retained = releaseCheckpoints({ session, store, octokit });
51
+ const manifest = await retained.readCheckpoint(snapshots[0]);
52
+ if (
53
+ manifest.source?.repository !== repository ||
54
+ manifest.source.version !== intent.key ||
55
+ manifest.source.targetRef !== targetRef
56
+ )
57
+ throw new Error("Recovery material belongs to a different release intent");
58
+ const workspace = path.resolve(outputDir, "../..");
59
+ const prefix =
60
+ path
61
+ .relative(workspace, path.resolve(outputDir))
62
+ .split(path.sep)
63
+ .join("/") + "/";
64
+ if (!manifest.files.every((file) => file.path.startsWith(prefix)))
65
+ throw new Error(
66
+ "Candidate recovery cannot restore files outside its evidence directory",
67
+ );
68
+ const inputs = await restoreRecoveryMaterials(
69
+ manifest,
70
+ workspace,
71
+ retained.materials,
72
+ );
73
+ const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
74
+ const candidate = read(inputs["candidate-passport-path"]),
75
+ summary = read(inputs["candidate-build-summary-path"]);
76
+ const validation = validateReleaseCandidatePassport({
77
+ passport: candidate,
78
+ repository,
79
+ buildSummary: summary,
80
+ });
81
+ if (!validation.ok)
82
+ throw new Error(
83
+ `Recovered candidate is invalid: ${validation.errors.join("; ")}`,
84
+ );
85
+ const stageCapsules = read(inputs["stage-capsules-path"]),
86
+ qualification = read(inputs["publication-qualification-path"]);
87
+ assertCandidateEvidenceBinding({ candidate, stageCapsules, repository });
88
+ const sourceBinding = await observeProtectedPublicationSource({
89
+ octokit,
90
+ repository,
91
+ protectedSourceSha: targetSha,
92
+ candidate,
93
+ });
94
+ aggregateReleasePassport({
95
+ candidate,
96
+ stageCapsules,
97
+ qualification,
98
+ sourceBinding,
99
+ version: intent.key,
100
+ tag: `v${intent.key}`,
101
+ channel: manifest.source.channel,
102
+ });
103
+ // The shared candidate provider performs artifact-kind-specific sealed verification
104
+ // before effects; transport restoration verifies every retained file digest.
105
+ const paths = {
106
+ passport: inputs["candidate-passport-path"],
107
+ buildSummary: inputs["candidate-build-summary-path"],
108
+ stageCapsules: inputs["stage-capsules-path"],
109
+ publicationQualification: inputs["publication-qualification-path"],
110
+ sealedBundleRoot: inputs["sealed-bundle-root"],
111
+ sealedBundleManifest: inputs["sealed-bundle-manifest"],
112
+ publishRequiredArtifacts: inputs["required-artifacts-path"],
113
+ recoveryReceipt: inputs["recovery-receipt-path"] || "",
114
+ releaseAssets: (manifest.releaseAssets || []).map((file) =>
115
+ path.resolve(workspace, file),
116
+ ),
117
+ };
118
+ return {
119
+ enabled: true,
120
+ version: candidate.target.version,
121
+ publicationVersion: intent.key,
122
+ candidateVersion: candidate.target.version,
123
+ artifacts: {
124
+ sourceSha: candidate.source.headSha,
125
+ passport: `discussion-${discussion.number}`,
126
+ },
127
+ paths,
128
+ run: { id: snapshots[0].attempt.split(":")[0] },
129
+ discussionId,
130
+ handoff: observed.handoff,
131
+ };
132
+ }
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { createIntent, createProgress } from "./envelope.js";
5
+ import { releaseDiscussionStore } from "./store.js";
6
+ import { discussionTransport } from "../../providers/github/discussions/transport.js";
7
+
8
+ export const RELEASE_NODES = [
9
+ "qualification",
10
+ "publication",
11
+ "github-release",
12
+ "next-development",
13
+ "settlement",
14
+ ];
15
+
16
+ export function selectedRecordRuntime(env) {
17
+ const selection = JSON.parse(env.BUILDCHAIN_RUNTIME_SELECTION || "{}");
18
+ const file = path.join(
19
+ env.BUILDCHAIN_RUNTIME_ROOT || "",
20
+ "dist/readers/release-discussion.cjs",
21
+ );
22
+ return {
23
+ repository: selection.repository,
24
+ sha: selection.sha,
25
+ readerPath: "dist/readers/release-discussion.cjs",
26
+ readerDigest: `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`,
27
+ };
28
+ }
29
+
30
+ export async function openReleaseSession({
31
+ graphql,
32
+ repository,
33
+ key,
34
+ source,
35
+ runtime,
36
+ attempt,
37
+ discussionId = "",
38
+ predecessor = "",
39
+ dryRun = false,
40
+ recover = false,
41
+ expectedNodes = RELEASE_NODES,
42
+ }) {
43
+ const intent = createIntent({
44
+ repository,
45
+ key,
46
+ source,
47
+ runtime,
48
+ expectedNodes,
49
+ });
50
+ const store = releaseDiscussionStore(discussionTransport(graphql));
51
+ const session = await store.initialize({ intent, discussionId, dryRun });
52
+ if (dryRun)
53
+ return {
54
+ session,
55
+ observe: async (_node, effect) => effect(),
56
+ record: async () => {},
57
+ read: async () => ({ dryRun: true }),
58
+ };
59
+ const current = await store.read(session);
60
+ const retained = current.records.find((event) => event.attempt === attempt);
61
+ const prior =
62
+ retained?.predecessor ??
63
+ (predecessor || (recover ? current.attempt || "" : ""));
64
+ if (
65
+ current.attempt &&
66
+ current.attempt !== attempt &&
67
+ prior !== current.attempt
68
+ )
69
+ throw new Error(
70
+ `Recovery requires predecessor ${current.attempt} in the same Discussion`,
71
+ );
72
+ session.attempt = attempt;
73
+ session.predecessor = prior;
74
+ session.runtime = runtime;
75
+ const record = async (node, status, payload = {}, sequence = 0) =>
76
+ store.append(
77
+ session,
78
+ createProgress({
79
+ intent: session.intent,
80
+ runtime,
81
+ attempt,
82
+ predecessor: prior,
83
+ node,
84
+ status,
85
+ payload,
86
+ sequence,
87
+ }),
88
+ );
89
+ await record("attempt", "running");
90
+ const observe = async (node, effect, summarize = () => ({})) => {
91
+ await record(node, "running");
92
+ let result;
93
+ try {
94
+ result = await effect();
95
+ } catch (error) {
96
+ // Preserve the original failure even when the diagnostic provider is down.
97
+ try {
98
+ await record(
99
+ node,
100
+ "failure",
101
+ { code: String(error.code || "execution-failed") },
102
+ 1,
103
+ );
104
+ } catch (recordError) {
105
+ error.discussionRecordingError = recordError.message;
106
+ }
107
+ throw error;
108
+ }
109
+ await record(node, "success", summarize(result), 1);
110
+ return result;
111
+ };
112
+ return { session, store, observe, record, read: () => store.read(session) };
113
+ }
@@ -0,0 +1,169 @@
1
+ import { collectDiscussionPages } from "../../providers/github/discussions/transport.js";
2
+ import {
3
+ canonicalJson,
4
+ decodeRecord,
5
+ encodeRecord,
6
+ INTENT_SCHEMA,
7
+ } from "./envelope.js";
8
+ import { readReleaseDiscussion } from "./reader.js";
9
+
10
+ function assertDiscussion(discussion, intent, writerId) {
11
+ if (
12
+ discussion.repository.nameWithOwner.toLowerCase() !==
13
+ intent.repository.toLowerCase()
14
+ )
15
+ throw new Error("Discussion belongs to a different consumer repository");
16
+ if (discussion.author?.id !== writerId || discussion.lastEditedAt)
17
+ throw new Error("Discussion intent has an untrusted author or was edited");
18
+ const existing = decodeRecord(discussion.body);
19
+ if (existing?.id !== intent.id || existing.schema !== INTENT_SCHEMA)
20
+ throw new Error("Discussion contains a different release intent");
21
+ if (
22
+ canonicalJson(existing.expectedNodes) !==
23
+ canonicalJson(intent.expectedNodes) ||
24
+ canonicalJson(existing.source) !== canonicalJson(intent.source)
25
+ )
26
+ throw new Error("Release intent changed during recovery");
27
+ return existing;
28
+ }
29
+
30
+ export function releaseDiscussionStore(
31
+ transport,
32
+ { sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {},
33
+ ) {
34
+ async function observeAfterUnknown(readback, cause) {
35
+ if (
36
+ [401, 403].includes(cause.status) ||
37
+ cause.errors?.some((error) =>
38
+ ["FORBIDDEN", "UNAUTHORIZED"].includes(error.type),
39
+ )
40
+ )
41
+ throw new Error(
42
+ "Consumer workflow cannot write this Discussion; grant discussions: write and permit Announcements creation",
43
+ { cause },
44
+ );
45
+ for (let attempt = 0; attempt < 4; attempt++) {
46
+ const record = await readback();
47
+ if (record) return record;
48
+ if (attempt < 3) await sleep(250 * (attempt + 1));
49
+ }
50
+ throw new Error(
51
+ "Discussion mutation outcome is unknown; retry by reading the same intent before any further write",
52
+ { cause },
53
+ );
54
+ }
55
+ async function initialize({
56
+ intent,
57
+ discussionId = "",
58
+ category = "Announcements",
59
+ dryRun = false,
60
+ }) {
61
+ if (dryRun) return { dryRun: true, intent };
62
+ const repository = await transport.repository(intent.repository);
63
+ const writerId = repository.viewer.id;
64
+ if (discussionId) {
65
+ const discussion = await transport.get(discussionId);
66
+ const original = assertDiscussion(discussion, intent, writerId);
67
+ return { discussion, intent: original, writerId };
68
+ }
69
+ const selected = repository.discussionCategories.nodes.filter(
70
+ (entry) => entry.name === category,
71
+ );
72
+ if (selected.length !== 1)
73
+ throw new Error(
74
+ `Release Discussion category ${category} must exist exactly once`,
75
+ );
76
+ const find = async () => {
77
+ const candidates = await collectDiscussionPages((after) =>
78
+ transport.list(intent.repository, selected[0].id, after),
79
+ );
80
+ const matching = candidates.filter(
81
+ (discussion) =>
82
+ discussion.author?.id === writerId &&
83
+ decodeRecord(discussion.body)?.id === intent.id,
84
+ );
85
+ if (matching.length > 1)
86
+ throw new Error(
87
+ "Multiple Discussions own this release intent; refusing ambiguous recovery",
88
+ );
89
+ if (matching.length) assertDiscussion(matching[0], intent, writerId);
90
+ return matching[0];
91
+ };
92
+ let discussion = await find();
93
+ if (!discussion) {
94
+ try {
95
+ discussion = await transport.create({
96
+ repositoryId: repository.id,
97
+ categoryId: selected[0].id,
98
+ title: `Buildchain release: ${intent.key}`,
99
+ body: encodeRecord(
100
+ intent,
101
+ `Release transaction for ${intent.key}. Expected nodes: ${intent.expectedNodes.join(", ")}.`,
102
+ ),
103
+ });
104
+ } catch (error) {
105
+ discussion = await observeAfterUnknown(find, error);
106
+ }
107
+ }
108
+ const original = assertDiscussion(discussion, intent, writerId);
109
+ return { discussion, intent: original, writerId };
110
+ }
111
+ async function read(session) {
112
+ const discussion = await transport.get(session.discussion.id);
113
+ assertDiscussion(discussion, session.intent, session.writerId);
114
+ const comments = await collectDiscussionPages((after) =>
115
+ transport.comments(discussion.id, after),
116
+ );
117
+ const trusted = comments.filter(
118
+ (comment) => comment.author?.id === session.writerId,
119
+ );
120
+ if (
121
+ trusted.some(
122
+ (comment) => comment.lastEditedAt && decodeRecord(comment.body),
123
+ )
124
+ )
125
+ throw new Error(
126
+ "A transaction record was edited; historical facts are no longer intact",
127
+ );
128
+ const records = trusted
129
+ .map((comment) => decodeRecord(comment.body))
130
+ .filter(Boolean);
131
+ return {
132
+ discussion,
133
+ comments: trusted,
134
+ records,
135
+ ...readReleaseDiscussion({ body: discussion.body, records }),
136
+ };
137
+ }
138
+ async function append(session, record) {
139
+ if (session.dryRun) return { dryRun: true };
140
+ const body = encodeRecord(
141
+ record,
142
+ `${record.node}: ${record.status} (attempt ${record.attempt})`,
143
+ );
144
+ const find = async () => {
145
+ const observed = await read(session);
146
+ const existing = observed.comments.find(
147
+ (comment) => decodeRecord(comment.body)?.id === record.id,
148
+ );
149
+ if (!existing)
150
+ readReleaseDiscussion({
151
+ body: observed.discussion.body,
152
+ records: [...observed.records, record],
153
+ });
154
+ return existing;
155
+ };
156
+ const existing = await find();
157
+ if (existing) return existing;
158
+ try {
159
+ await transport.append(session.discussion.id, body);
160
+ } catch (error) {
161
+ return observeAfterUnknown(find, error);
162
+ }
163
+ return observeAfterUnknown(
164
+ find,
165
+ new Error("Discussion append requires provider readback"),
166
+ );
167
+ }
168
+ return { initialize, read, append };
169
+ }
@@ -302,6 +302,7 @@ export async function publishGitHubReleaseEvidence({
302
302
  declarationPath,
303
303
  qualificationRoot = "",
304
304
  failureAfterCapability = "",
305
+ checkpoint: retainCheckpoint,
305
306
  } = {}) {
306
307
  const assetPaths = collectGitHubReleaseEvidenceAssets({
307
308
  publishEvidencePath,
@@ -349,8 +350,9 @@ export async function publishGitHubReleaseEvidence({
349
350
  }),
350
351
  ...createGitHubProviderAdapters(octokit, materialized.documents),
351
352
  }),
352
- checkpoint: (checkpoint) => {
353
+ checkpoint: async (checkpoint) => {
353
354
  writeReleaseTailTransaction(resolvedStatePath, checkpoint);
355
+ if (retainCheckpoint) await retainCheckpoint(checkpoint);
354
356
  if (
355
357
  failureAfterCapability &&
356
358
  checkpoint.receipts.some(
@@ -1,9 +1,89 @@
1
1
  import * as github from "@actions/github";
2
- import { promoteReleaseCandidate } from "./transaction.js";
2
+ import { publishWithDiscussion } from "../discussion/publication.js";
3
+ import { selectedRecordRuntime } from "../discussion/session.js";
3
4
  export async function candidatePublicationAction(core, env) {
4
- const required = new Set(["candidate-build-summary-path", "required-artifacts-path", "target-ref", "target-sha", "candidate-passport-path", "channel", "product-publication-intent-path", "publication-qualification-path", "publisher-workflow-sha", "repository", "runtime-commit", "runtime-tree", "source-sha", "stage-capsules-path", "tag", "token", "version"]);
5
- const request = Object.fromEntries(["recovery-receipt-path", "candidate-build-summary-path", "candidate-passport-path", "channel", "failure-after-capability", "mutation-token", "product-publication-intent-path", "publication-qualification-path", "publish-artifact-kind", "publish-auth", "publish-command", "publish-dist-tag", "publish-mode", "publish-package-main", "publish-package-set-order", "publisher-workflow-sha", "repository", "required-artifacts-path", "required-status-check", "resume-transaction-id", "runtime-commit", "runtime-tree", "sealed-bundle-manifest", "sealed-bundle-root", "source-sha", "stage-capsules-path", "state-path", "tag", "target-ref", "target-sha", "token", "version"].map(name => [name, core.getInput(name, { required: required.has(name) }).trim()]));
6
- for (const name of ["publish-rematerialize-on-resume", "publish-transaction-override"]) request[name] = core.getBooleanInput(name);
7
- request["artifact-paths"] = core.getMultilineInput("artifact-paths").filter(Boolean);
8
- return promoteReleaseCandidate(request, { octokit: github.getOctokit(request.token), mutationOctokit: github.getOctokit(request["mutation-token"] || request.token), actor: env.GITHUB_ACTOR, runId: env.GITHUB_RUN_ID || "", observe: outputs => { for (const [key, value] of Object.entries(outputs)) core.setOutput(key, value); } });
5
+ const required = new Set([
6
+ "candidate-build-summary-path",
7
+ "required-artifacts-path",
8
+ "target-ref",
9
+ "target-sha",
10
+ "candidate-passport-path",
11
+ "channel",
12
+ "product-publication-intent-path",
13
+ "publication-qualification-path",
14
+ "publisher-workflow-sha",
15
+ "repository",
16
+ "runtime-commit",
17
+ "runtime-tree",
18
+ "source-sha",
19
+ "stage-capsules-path",
20
+ "tag",
21
+ "token",
22
+ "version",
23
+ ]);
24
+ const request = Object.fromEntries(
25
+ [
26
+ "recovery-receipt-path",
27
+ "candidate-build-summary-path",
28
+ "candidate-passport-path",
29
+ "channel",
30
+ "failure-after-capability",
31
+ "mutation-token",
32
+ "product-publication-intent-path",
33
+ "publication-qualification-path",
34
+ "publish-artifact-kind",
35
+ "publish-auth",
36
+ "publish-command",
37
+ "publish-dist-tag",
38
+ "publish-mode",
39
+ "publish-package-main",
40
+ "publish-package-set-order",
41
+ "publisher-workflow-sha",
42
+ "repository",
43
+ "required-artifacts-path",
44
+ "required-status-check",
45
+ "resume-transaction-id",
46
+ "resume-discussion-id",
47
+ "runtime-commit",
48
+ "runtime-tree",
49
+ "sealed-bundle-manifest",
50
+ "sealed-bundle-root",
51
+ "source-sha",
52
+ "stage-capsules-path",
53
+ "state-path",
54
+ "tag",
55
+ "target-ref",
56
+ "target-sha",
57
+ "token",
58
+ "version",
59
+ ].map((name) => [
60
+ name,
61
+ core.getInput(name, { required: required.has(name) }).trim(),
62
+ ]),
63
+ );
64
+ for (const name of [
65
+ "publish-rematerialize-on-resume",
66
+ "publish-transaction-override",
67
+ "standalone-binary-distribution",
68
+ ])
69
+ request[name] = core.getBooleanInput(name);
70
+ request["artifact-paths"] = core
71
+ .getMultilineInput("artifact-paths")
72
+ .filter(Boolean);
73
+ return publishWithDiscussion(request, {
74
+ octokit: github.getOctokit(request.token),
75
+ mutationOctokit: github.getOctokit(
76
+ request["mutation-token"] || request.token,
77
+ ),
78
+ actor: env.GITHUB_ACTOR,
79
+ runId: env.GITHUB_RUN_ID,
80
+ attempt: `${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT}:${env.GITHUB_ACTION}`,
81
+ runtime: selectedRecordRuntime(env),
82
+ workspace: env.GITHUB_WORKSPACE,
83
+ runtimeRoot: env.BUILDCHAIN_RUNTIME_ROOT,
84
+ observe: (outputs) => {
85
+ for (const [key, value] of Object.entries(outputs))
86
+ core.setOutput(key, value);
87
+ },
88
+ });
9
89
  }