@agent-delivery-harness/action 0.1.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 ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ delivery-harness
2
+ Copyright the delivery-harness authors
3
+
4
+ This product includes software developed by the delivery-harness authors
5
+ (https://github.com/kwam1na/agent-delivery-harness).
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@agent-delivery-harness/action",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "GitHub Action verifying a tracked delivery record on pull requests",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "dependencies": {
11
+ "@agent-delivery-harness/kernel": "0.1.0"
12
+ },
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "LICENSE",
20
+ "NOTICE"
21
+ ]
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Delivery harness GitHub Action.
3
+ *
4
+ * The package barrel. `action.yml` runs `src/main.ts` directly — a composite
5
+ * action's entry point is a file path, not an import — so this exists for the
6
+ * consumers that reach for the Action's surface as a library: the self-hosting
7
+ * workflow's tests, and anything that wants to drive the verification from a
8
+ * simulated event without a runner.
9
+ */
10
+ export const PACKAGE_NAME = "@agent-delivery-harness/action";
11
+
12
+ export {
13
+ ACTION_EXIT_OK,
14
+ ACTION_EXIT_POLICY,
15
+ ACTION_MODES,
16
+ CI_POLICY_INPUT_ENV,
17
+ defaultRuntime,
18
+ importHarnessConfig,
19
+ main,
20
+ runAction,
21
+ type ActionMode,
22
+ type ActionResult,
23
+ type ActionRuntime,
24
+ } from "./main.ts";
package/src/main.ts ADDED
@@ -0,0 +1,1109 @@
1
+ /**
2
+ * The GitHub Action: fail-closed verification of the tracked delivery record.
3
+ *
4
+ * A THIN WRAPPER, DELIBERATELY. Everything this file decides about a record is
5
+ * decided by `verifyDeliveryRecord` in the kernel — the same function the CLI
6
+ * `verify` command calls, reading the same `deliveryRecordVerification` policy.
7
+ * No verification logic lives here. What lives here is the part that is specific
8
+ * to running inside a pull request: which commit to recompute the deliverable
9
+ * identity from, how to find the record in the tracked tree, how to decide
10
+ * whether the run may claim delegated authority, and how to render the answer as
11
+ * a check summary. If a rule about records ever needs to change, it changes in
12
+ * the kernel and both surfaces move together; a rule that only CI enforced would
13
+ * make the local gate more permissive than the merge gate, which is the one
14
+ * asymmetry this design refuses.
15
+ *
16
+ * WHY THE PULL REQUEST HEAD, AND NEVER THE SYNTHETIC MERGE COMMIT.
17
+ *
18
+ * On a `pull_request` event GitHub checks out — and points `GITHUB_SHA` at — a
19
+ * commit it fabricates by merging the head into the current base tip. That
20
+ * commit is not the head. Three consequences, each of them fatal to a record
21
+ * check:
22
+ *
23
+ * 1. Its tree contains base changes the author never delivered, so its
24
+ * deliverable identity is not the identity any local gate ever saw. A
25
+ * record that verified against it would be attesting a tree nobody
26
+ * reviewed and nobody will ever merge under that hash.
27
+ * 2. It is regenerated whenever the base moves. The verified artifact would
28
+ * change under a PR that did not change, which makes the check's answer a
29
+ * function of other people's merges.
30
+ * 3. It would silently *launder* base movement. The whole point of the
31
+ * `baseMovement` policy is that a moved base stales a record unless the
32
+ * consumer says otherwise; verifying a tree that already has the new base
33
+ * folded in would make every record look fresh.
34
+ *
35
+ * So the head sha is read out of the event payload and the identity is
36
+ * recomputed from *that commit's tree* through git, not from the working tree.
37
+ * That is stronger than trusting the checkout: even a workflow that checked out
38
+ * the merge ref by mistake cannot make this pass, because the working tree is
39
+ * never consulted for identity. `GITHUB_SHA` is recorded in the summary as the
40
+ * commit that was *not* verified, and is resolved nowhere.
41
+ *
42
+ * NO CLOCK (sensor rule e). Nothing here reads a clock. A record's freshness is
43
+ * its identity's agreement with the head, never elapsed time, and an Action that
44
+ * consulted a clock would be able to expire evidence the kernel considers valid.
45
+ *
46
+ * FAIL CLOSED, ALWAYS. Every path that cannot reach a verdict — an event that is
47
+ * not a pull request, a payload without a head, a config that will not load, a
48
+ * head commit the checkout does not have, an unresolvable base, a record that
49
+ * will not parse — produces typed blockers and a non-zero exit. There is no
50
+ * skip: a check that reports success because it could not run is worse than no
51
+ * check, because it looks like one.
52
+ */
53
+ import { realpathSync } from "node:fs";
54
+ import { access, appendFile, readFile } from "node:fs/promises";
55
+ import path from "node:path";
56
+ import { fileURLToPath, pathToFileURL } from "node:url";
57
+ import {
58
+ ATTESTATION_LABEL,
59
+ BlockedError,
60
+ DELIVERY_RECORD_DRIFT_CLASSES,
61
+ RESOLUTION_OUTCOMES,
62
+ classifyExecutionContext,
63
+ computeDeliverableIdentity,
64
+ createBlocker,
65
+ createInternalErrorBlocker,
66
+ deliveryRecordPathFor,
67
+ parseDeliveryRecord,
68
+ renderBlockers,
69
+ runGitCommand,
70
+ selectDeliveryRecordForIdentity,
71
+ validateHarnessConfig,
72
+ verifyDeliveryRecord,
73
+ type Blocker,
74
+ type BlockerSource,
75
+ type CandidateCommandRunner,
76
+ type DeliveryRecordCheck,
77
+ type DeliveryRecordClaim,
78
+ type DeliveryRecordFile,
79
+ type EnvSnapshot,
80
+ type HarnessConfig,
81
+ type NonEmptyTuple,
82
+ type Remediation,
83
+ } from "@agent-delivery-harness/kernel";
84
+
85
+ // ── Exit codes ───────────────────────────────────────────────────────────────
86
+
87
+ /** The check passed. */
88
+ export const ACTION_EXIT_OK = 0;
89
+ /** The check produced blockers, or could not be run. Both are failures. */
90
+ export const ACTION_EXIT_POLICY = 1;
91
+
92
+ // ── Modes ────────────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * What the run claims to be.
96
+ *
97
+ * `verify` is unprivileged: it recomputes an identity, reads a tracked file, and
98
+ * reports. It grants nothing, so it needs no authorization and the execution
99
+ * ladder is not consulted for it.
100
+ *
101
+ * `delegated-authority` is a claim, made by passing the `ci-policy-id` input,
102
+ * that this job is the repository-authorized automation a declared CI policy
103
+ * names. The claim is checked through `classifyExecutionContext` against the
104
+ * consumer's own policies, and it is checked *exactly*.
105
+ *
106
+ * `delegation-refused` is what a failed claim reports, and it is a distinct mode
107
+ * rather than a fall back to `verify` for the reason the whole ladder exists: a
108
+ * refused claim reported as `verify` IS the quiet downgrade. An operator reading
109
+ * `verify` on a job they configured to run under a policy would conclude the
110
+ * policy was irrelevant rather than that it did not hold, and a dashboard
111
+ * grouping runs by mode would file the refusal alongside every ordinary check.
112
+ * The mode names what happened; the blocker says why.
113
+ */
114
+ export const ACTION_MODES = ["verify", "delegated-authority", "delegation-refused"] as const;
115
+ export type ActionMode = (typeof ACTION_MODES)[number];
116
+
117
+ /**
118
+ * The env var `action.yml` maps the `ci-policy-id` input onto.
119
+ *
120
+ * An explicit name rather than GitHub's `INPUT_*` mangling, and deliberately not
121
+ * the consumer's own `ciPolicyEnvKey`: this is plumbing for one input, while
122
+ * `ciPolicyEnvKey` is the vendor-neutral member a *policy* is declared under.
123
+ * The two meet in one place — the value read here is overlaid onto the config's
124
+ * key before classification — so the classifier still sees only config-named
125
+ * variables and the Action contributes no literal to the policy grammar.
126
+ */
127
+ export const CI_POLICY_INPUT_ENV = "DELIVERY_HARNESS_CI_POLICY_ID";
128
+
129
+ // ── The runtime seam ─────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Everything the Action may reach, handed in rather than reached for.
133
+ *
134
+ * This is what lets the whole failure-class table be driven from simulated event
135
+ * payloads with no Actions runner: a test supplies an env snapshot, an event
136
+ * file, and a repository, and calls `runAction` as a function. `git` is a port
137
+ * rather than a fake — the tests point it at real repositories, because a
138
+ * simulated git would let the head-vs-merge-ref proof pass against a fiction.
139
+ */
140
+ export interface ActionRuntime {
141
+ readonly env: EnvSnapshot;
142
+ /** The checked-out repository root. Used as git's cwd, never as the identity source. */
143
+ readonly workspace: string;
144
+ readonly git: CandidateCommandRunner;
145
+ readonly readFile: (absolutePath: string) => Promise<string>;
146
+ readonly loadConfig: (rootDir: string) => Promise<HarnessConfig>;
147
+ /** Emits the check summary. In a runner this appends to `$GITHUB_STEP_SUMMARY`. */
148
+ readonly writeSummary: (markdown: string) => Promise<void>;
149
+ readonly log: (line: string) => void;
150
+ /** Optional: step outputs. Absent in tests, which read the returned result instead. */
151
+ readonly writeOutputs?: (outputs: Readonly<Record<string, string>>) => Promise<void>;
152
+ /** Optional: whether a path exists in the workspace. Defaults to a real stat. */
153
+ readonly pathExists?: (absolutePath: string) => Promise<boolean>;
154
+ }
155
+
156
+ export interface ActionResult {
157
+ readonly ok: boolean;
158
+ readonly exitCode: number;
159
+ readonly mode: ActionMode;
160
+ /** The markdown handed to `writeSummary`, returned so tests read what CI reads. */
161
+ readonly summary: string;
162
+ readonly blockers: readonly Blocker[];
163
+ /** The commit whose tree was verified. */
164
+ readonly headSha: string | null;
165
+ /** `GITHUB_SHA` — recorded for the record, resolved nowhere. */
166
+ readonly mergeRefSha: string | null;
167
+ readonly recordPath: string | null;
168
+ readonly deliverableDigest: string | null;
169
+ }
170
+
171
+ // ── Blockers ─────────────────────────────────────────────────────────────────
172
+
173
+ /** `git ls-tree -z` separates records with NUL, so a path containing a newline stays one record. */
174
+ const NUL = "\u0000";
175
+
176
+ const ACTION_SOURCE: BlockerSource = { kind: "command", id: "delivery-harness.action" };
177
+
178
+ const RECORD_AND_COMMIT: Remediation = {
179
+ id: "record-and-commit",
180
+ kind: "command",
181
+ command: ["delivery-harness", "record"],
182
+ summary: "Run the gate locally, record the admitted result, then commit the record file with the change it describes.",
183
+ };
184
+
185
+ const RE_RUN_THE_LOOP: Remediation = {
186
+ id: "re-run-the-loop",
187
+ kind: "manual_action",
188
+ summary: "Re-prepare, re-run the gate, and re-record for the pull request head as it now stands.",
189
+ };
190
+
191
+ function actionBlocker(input: {
192
+ readonly code: string;
193
+ readonly summary: string;
194
+ readonly details?: string;
195
+ readonly remediations: NonEmptyTuple<Remediation>;
196
+ }): Blocker {
197
+ return createBlocker({
198
+ code: input.code,
199
+ source: ACTION_SOURCE,
200
+ summary: input.summary,
201
+ ...(input.details === undefined ? {} : { details: input.details }),
202
+ remediations: input.remediations,
203
+ });
204
+ }
205
+
206
+ /**
207
+ * The drift class the Action names when no tracked record binds to the head.
208
+ *
209
+ * Bound to the kernel's own vocabulary rather than spelled independently: this
210
+ * declaration fails to compile if the kernel ever renames the class, which is
211
+ * the only way a wrapper can promise it is speaking the same language as the
212
+ * core it wraps.
213
+ */
214
+ const IDENTITY_DRIFT_CLASS: (typeof DELIVERY_RECORD_DRIFT_CLASSES)[number] = "deliverable_identity_changed";
215
+
216
+ // ── Printable vocabulary ─────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * WHY THE SUMMARY PRINTS ALLOWLISTED VALUES RATHER THAN RECORD TEXT.
220
+ *
221
+ * A delivery record is committed by the pull request author, so every string in
222
+ * it is untrusted. Untrusted text has exactly one sanctioned route to an
223
+ * operator: the blocker renderer, which neutralizes per §11.2 and is the shared
224
+ * rail all three surfaces use. The claims table is not a blocker, so instead of
225
+ * inventing a second neutralization chain — two chains drift, and the weaker one
226
+ * is always the one that matters — this file prints only values that satisfy a
227
+ * closed grammar or appear in the consumer's own config. A control character, an
228
+ * ANSI escape, a bidi override and a table-breaking pipe are all outside every
229
+ * grammar below, so none of them can reach the summary at all.
230
+ */
231
+ const ID_GRAMMAR = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
232
+ const TOKEN_GRAMMAR = /^[A-Za-z0-9._/-]{1,80}$/;
233
+ const SHA_GRAMMAR = /^[0-9a-f]{7,64}$/;
234
+
235
+ /**
236
+ * What a head sha must look like at the payload boundary: a *full* object name.
237
+ *
238
+ * Wider than the display grammar on purpose. GitHub always sends 40 hex in
239
+ * `pull_request.head.sha`, so an abbreviation arriving there is a rewritten or
240
+ * hand-assembled payload, and an abbreviation is ambiguous by construction —
241
+ * `git rev-parse` would happily resolve it to whichever object happens to share
242
+ * the prefix. The commit whose tree an entire verification hangs on is the last
243
+ * place to accept a value that git gets to interpret.
244
+ */
245
+ const HEAD_SHA_GRAMMAR = /^[0-9a-f]{40}$/;
246
+ const DIGEST_GRAMMAR = /^[0-9a-f]{64}$/;
247
+
248
+ const UNPRINTABLE = "[unprintable]";
249
+
250
+ function printable(value: unknown, grammar: RegExp, maximumLength = 120): string {
251
+ if (typeof value !== "string" || value.length > maximumLength || !grammar.test(value)) return UNPRINTABLE;
252
+ return value;
253
+ }
254
+
255
+ // ── Git helpers ──────────────────────────────────────────────────────────────
256
+
257
+ interface GitPort {
258
+ (args: readonly string[]): Promise<{ readonly exitCode: number; readonly stdout: string; readonly stderr: string }>;
259
+ }
260
+
261
+ function gitPortFor(runtime: ActionRuntime): GitPort {
262
+ return (args) => runtime.git(["git", ...args], { cwd: runtime.workspace });
263
+ }
264
+
265
+ // ── The event ────────────────────────────────────────────────────────────────
266
+
267
+ /** The pull-request event names this Action accepts, and the one it refuses by name. */
268
+ const VERIFIABLE_EVENT = "pull_request";
269
+ const REFUSED_EVENT = "pull_request_target";
270
+
271
+ interface PullRequestEvent {
272
+ readonly headSha: string;
273
+ readonly headRef: string | null;
274
+ readonly number: number | null;
275
+ }
276
+
277
+ type EventResolution = { readonly ok: true; readonly event: PullRequestEvent } | { readonly ok: false; readonly blockers: NonEmptyTuple<Blocker> };
278
+
279
+ function readMember(container: unknown, key: string): unknown {
280
+ if (container === null || typeof container !== "object" || Array.isArray(container)) return undefined;
281
+ return (container as Record<string, unknown>)[key];
282
+ }
283
+
284
+ async function resolvePullRequestEvent(runtime: ActionRuntime): Promise<EventResolution> {
285
+ const eventName = runtime.env["GITHUB_EVENT_NAME"];
286
+
287
+ // `pull_request_target` is refused by name rather than tolerated. It runs with
288
+ // the base repository's privileges while describing a head the base has not
289
+ // reviewed; a verification job with write-capable credentials looking at
290
+ // untrusted code is the shape of the supply-chain incident this whole harness
291
+ // exists to make harder. The consumer wants `pull_request`, which is exactly
292
+ // as capable for a read-only check.
293
+ if (eventName === REFUSED_EVENT) {
294
+ return {
295
+ ok: false,
296
+ blockers: [
297
+ actionBlocker({
298
+ code: "unsupported_event",
299
+ summary: `The ${REFUSED_EVENT} event is refused: it runs with base-repository privileges against an unreviewed head.`,
300
+ details: `Trigger this check on ${VERIFIABLE_EVENT}, which is read-only and sufficient for record verification.`,
301
+ remediations: [
302
+ {
303
+ id: "trigger-on-pull-request",
304
+ kind: "code_change",
305
+ summary: `Trigger the verification workflow on ${VERIFIABLE_EVENT} with \`permissions: contents: read\`.`,
306
+ },
307
+ ],
308
+ }),
309
+ ],
310
+ };
311
+ }
312
+
313
+ if (eventName !== VERIFIABLE_EVENT) {
314
+ return {
315
+ ok: false,
316
+ blockers: [
317
+ actionBlocker({
318
+ code: "unsupported_event",
319
+ summary: "This check verifies a pull request head; the delivery was not a pull request event.",
320
+ details: `GITHUB_EVENT_NAME was ${JSON.stringify(eventName ?? null)}; expected ${JSON.stringify(VERIFIABLE_EVENT)}.`,
321
+ remediations: [
322
+ { id: "trigger-on-pull-request", kind: "code_change", summary: `Trigger the verification workflow on ${VERIFIABLE_EVENT}.` },
323
+ ],
324
+ }),
325
+ ],
326
+ };
327
+ }
328
+
329
+ const eventPath = runtime.env["GITHUB_EVENT_PATH"];
330
+ const unreadable = (detail: string): EventResolution => ({
331
+ ok: false,
332
+ blockers: [
333
+ actionBlocker({
334
+ code: "event_payload_unreadable",
335
+ summary: "The pull request event payload could not be read.",
336
+ details: detail,
337
+ remediations: [
338
+ {
339
+ id: "inspect-workflow-trigger",
340
+ kind: "manual_action",
341
+ summary: "Inspect the workflow trigger: the runner did not provide a readable pull_request payload.",
342
+ },
343
+ ],
344
+ }),
345
+ ],
346
+ });
347
+
348
+ if (typeof eventPath !== "string" || eventPath === "") return unreadable("GITHUB_EVENT_PATH is unset.");
349
+
350
+ let payload: unknown;
351
+ try {
352
+ payload = JSON.parse(await runtime.readFile(eventPath));
353
+ } catch (error) {
354
+ return unreadable(`${eventPath}: ${error instanceof Error ? error.message : String(error)}`);
355
+ }
356
+
357
+ const pullRequest = readMember(payload, "pull_request");
358
+ const headSha = readMember(readMember(pullRequest, "head"), "sha");
359
+ if (typeof headSha !== "string" || !HEAD_SHA_GRAMMAR.test(headSha)) {
360
+ return unreadable(`the payload carries no usable pull_request.head.sha (${JSON.stringify(headSha ?? null)})`);
361
+ }
362
+ const headRef = readMember(readMember(pullRequest, "head"), "ref");
363
+ const number = readMember(pullRequest, "number");
364
+ return {
365
+ ok: true,
366
+ event: {
367
+ headSha,
368
+ headRef: typeof headRef === "string" ? headRef : null,
369
+ number: typeof number === "number" && Number.isSafeInteger(number) ? number : null,
370
+ },
371
+ };
372
+ }
373
+
374
+ // ── Delegated authority ──────────────────────────────────────────────────────
375
+
376
+ interface ModeResolution {
377
+ readonly mode: ActionMode;
378
+ readonly policyId: string | null;
379
+ readonly blockers: readonly Blocker[];
380
+ }
381
+
382
+ function resolveMode(runtime: ActionRuntime, config: HarnessConfig): ModeResolution {
383
+ const declared = runtime.env[CI_POLICY_INPUT_ENV];
384
+ // Absent input, absent claim. Verify mode grants nothing, so there is nothing
385
+ // to authorize and the ladder is not consulted. A run that wants delegated
386
+ // authority has to ask for it.
387
+ if (typeof declared !== "string" || declared.trim() === "") {
388
+ return { mode: "verify", policyId: null, blockers: [] };
389
+ }
390
+ const policyId = declared.trim();
391
+
392
+ // The classifier reads a given snapshot, so the input is overlaid onto the
393
+ // *config's* policy key: the classifier never learns this Action's plumbing
394
+ // name, and the consumer's corroboration surface is the only thing that can
395
+ // grant the rung. Streams are non-interactive by construction on a runner.
396
+ const context = classifyExecutionContext({
397
+ config,
398
+ env: { ...runtime.env, [config.ciPolicyEnvKey]: policyId },
399
+ stdinIsTTY: false,
400
+ stdoutIsTTY: false,
401
+ });
402
+
403
+ if (context.kind === "ci" && context.policyId === policyId) {
404
+ return { mode: "delegated-authority", policyId, blockers: [] };
405
+ }
406
+
407
+ const corroboration = config.ciPolicies
408
+ .find((policy) => policy.id === policyId)
409
+ ?.requiredEnv.map((requirement) => `${requirement.variable}=${JSON.stringify(requirement.equals)}`)
410
+ .join(", ");
411
+ return {
412
+ // Never `verify`. The claim was made and refused, and the mode has to say
413
+ // so — reporting the refusal as an ordinary unprivileged run is the quiet
414
+ // downgrade this whole path exists to prevent. The claimed id is carried so
415
+ // the summary can name what was refused rather than only that something was.
416
+ mode: "delegation-refused",
417
+ policyId,
418
+ blockers: [
419
+ actionBlocker({
420
+ code: "unauthorized_automation",
421
+ // Stated as a refusal rather than as a downgrade, because a downgrade is
422
+ // the failure mode: a job whose environment half-matches a declared
423
+ // policy must not keep running with whatever rights it lands next to.
424
+ summary: `This run claims the delegated-authority policy ${JSON.stringify(printable(policyId, ID_GRAMMAR))} but the environment does not corroborate it.`,
425
+ details:
426
+ corroboration === undefined
427
+ ? `No CI policy with that id is declared in this repository's configuration.`
428
+ : `The declared policy requires ${corroboration}; the run's environment does not match it completely.`,
429
+ remediations: [
430
+ {
431
+ id: "repair-the-delegation-environment",
432
+ kind: "manual_action",
433
+ summary: "Repair the job's environment so it matches one declared CI policy completely, or drop the ci-policy-id input and run in verify mode.",
434
+ },
435
+ ],
436
+ }),
437
+ ],
438
+ };
439
+ }
440
+
441
+ // ── Record discovery ─────────────────────────────────────────────────────────
442
+
443
+ /**
444
+ * The prefix and suffix a delivery record's *derived* filename has under this
445
+ * config, discovered by splicing two digests that differ in every position.
446
+ *
447
+ * Derived from `deliveryRecordPathFor` rather than re-deriving the splice: the
448
+ * naming rule lives in the kernel, and a second implementation of it here would
449
+ * be free to drift into a lookup that silently finds nothing. Two probes that
450
+ * agree nowhere pin the splice window exactly — everything the two derived paths
451
+ * share is config, everything they do not is digest.
452
+ */
453
+ function recordNameShape(config: HarnessConfig): { readonly prefix: string; readonly suffix: string } {
454
+ const low = deliveryRecordPathFor(config, "0".repeat(64));
455
+ const high = deliveryRecordPathFor(config, "f".repeat(64));
456
+ let prefixLength = 0;
457
+ while (prefixLength < low.length && low[prefixLength] === high[prefixLength]) prefixLength += 1;
458
+ let suffixLength = 0;
459
+ while (
460
+ suffixLength < low.length - prefixLength &&
461
+ low[low.length - 1 - suffixLength] === high[high.length - 1 - suffixLength]
462
+ ) {
463
+ suffixLength += 1;
464
+ }
465
+ return { prefix: low.slice(0, prefixLength), suffix: suffixLength === 0 ? "" : low.slice(low.length - suffixLength) };
466
+ }
467
+
468
+ function isRecordPath(candidatePath: string, shape: { readonly prefix: string; readonly suffix: string }): boolean {
469
+ if (candidatePath.length !== shape.prefix.length + 64 + shape.suffix.length) return false;
470
+ if (!candidatePath.startsWith(shape.prefix) || !candidatePath.endsWith(shape.suffix)) return false;
471
+ return DIGEST_GRAMMAR.test(candidatePath.slice(shape.prefix.length, shape.prefix.length + 64));
472
+ }
473
+
474
+ interface DiscoveredRecords {
475
+ readonly files: readonly DeliveryRecordFile[];
476
+ /** Parse failures. Findings, never skips — see below. */
477
+ readonly blockers: readonly Blocker[];
478
+ readonly trackedCount: number;
479
+ }
480
+
481
+ /**
482
+ * Reads every record-shaped path out of the head commit's tree.
483
+ *
484
+ * READ FROM THE TREE, NOT FROM THE WORKING DIRECTORY. The record's whole claim
485
+ * is that it is *tracked* — a file the reviewer can see in the diff and the
486
+ * history retains. Reading it out of the commit makes that structural: an
487
+ * untracked file cannot be mistaken for evidence, whatever a build step left
488
+ * lying in the workspace.
489
+ *
490
+ * A PARSE FAILURE IS A FINDING, NEVER A SKIP. A file that matches this config's
491
+ * record naming template and cannot be read is a defect in the tracked tree, and
492
+ * a verifier that ignored it would be reporting on evidence it never inspected.
493
+ * The cost is real and accepted: a corrupt record committed long ago fails later
494
+ * pull requests until it is repaired or removed. That is the fail-closed
495
+ * direction, and the repair is a one-line commit.
496
+ */
497
+ async function readTrackedRecords(git: GitPort, headSha: string, config: HarnessConfig): Promise<DiscoveredRecords> {
498
+ const shape = recordNameShape(config);
499
+ // `--full-tree` for the same reason the identity computation uses it: without
500
+ // it, `ls-tree` is scoped to git's current directory and reports paths
501
+ // relative to it. Run under a `working-directory` below the repository root,
502
+ // discovery would then match a record at a truncated name and read its content
503
+ // from a root-level path of that name — a path/content confusion. The two
504
+ // sides of this check must be anchored identically or they are not comparing
505
+ // the same tree.
506
+ const listing = await git(["ls-tree", "-r", "--name-only", "-z", "--full-tree", headSha]);
507
+ if (listing.exitCode !== 0) {
508
+ return {
509
+ files: [],
510
+ trackedCount: 0,
511
+ blockers: [
512
+ actionBlocker({
513
+ code: "tracked_tree_unreadable",
514
+ summary: "The pull request head's tree could not be listed.",
515
+ details: listing.stderr.trim() || listing.stdout.trim() || `git ls-tree ${headSha} failed`,
516
+ remediations: [
517
+ {
518
+ id: "check-out-the-head-commit",
519
+ kind: "manual_action",
520
+ summary: "Check out the pull request head commit with enough history for git to read its tree.",
521
+ },
522
+ ],
523
+ }),
524
+ ],
525
+ };
526
+ }
527
+
528
+ const paths = listing.stdout.split(NUL).filter((entry) => entry.length > 0 && isRecordPath(entry, shape));
529
+ const files: DeliveryRecordFile[] = [];
530
+ const blockers: Blocker[] = [];
531
+ for (const recordPath of paths.sort()) {
532
+ const blob = await git(["cat-file", "blob", `${headSha}:${recordPath}`]);
533
+ if (blob.exitCode !== 0) {
534
+ blockers.push(
535
+ actionBlocker({
536
+ code: "delivery_record_unreadable",
537
+ summary: "A tracked delivery record could not be read out of the head commit.",
538
+ details: `${recordPath}: ${blob.stderr.trim() || `git cat-file failed`}`,
539
+ remediations: [RE_RUN_THE_LOOP],
540
+ }),
541
+ );
542
+ continue;
543
+ }
544
+ const parsed = parseDeliveryRecord(blob.stdout);
545
+ if (!parsed.ok) {
546
+ blockers.push(...parsed.blockers);
547
+ continue;
548
+ }
549
+ files.push({ path: recordPath, record: parsed.record });
550
+ }
551
+ return { files, blockers, trackedCount: paths.length };
552
+ }
553
+
554
+ // ── The summary ──────────────────────────────────────────────────────────────
555
+
556
+ interface SummaryInput {
557
+ readonly ok: boolean;
558
+ readonly mode: ActionMode;
559
+ readonly policyId: string | null;
560
+ readonly headSha: string | null;
561
+ readonly headRef: string | null;
562
+ readonly mergeRefSha: string | null;
563
+ readonly deliverableDigest: string | null;
564
+ readonly identityToken: string | null;
565
+ readonly recordPath: string | null;
566
+ readonly check: DeliveryRecordCheck | null;
567
+ readonly baseMovement: string | null;
568
+ readonly blockers: readonly Blocker[];
569
+ }
570
+
571
+ /**
572
+ * A code fence long enough to contain whatever the renderer produced.
573
+ *
574
+ * Neutralization removes control characters, not backticks — and a record is
575
+ * author-controlled text. Without this, a record carrying a fence could close
576
+ * the Action's block and write markdown that reads as harness-authored, which on
577
+ * a check summary is the difference between a report and a forgery.
578
+ */
579
+ function fenceFor(body: string): string {
580
+ let longestRun = 0;
581
+ let currentRun = 0;
582
+ for (const character of body) {
583
+ currentRun = character === "`" ? currentRun + 1 : 0;
584
+ if (currentRun > longestRun) longestRun = currentRun;
585
+ }
586
+ return "`".repeat(Math.max(3, longestRun + 1));
587
+ }
588
+
589
+ function claimRow(claim: DeliveryRecordClaim, config: HarnessConfig): string {
590
+ const obligationId = printable(claim.obligationId, ID_GRAMMAR);
591
+ const outcome = (RESOLUTION_OUTCOMES as readonly string[]).includes(claim.outcome) ? claim.outcome : UNPRINTABLE;
592
+ const provider =
593
+ claim.providerId === undefined
594
+ ? "—"
595
+ : config.providers.some((registration) => registration.id === claim.providerId)
596
+ ? printable(claim.providerId, ID_GRAMMAR)
597
+ : "[unregistered]";
598
+ const evidence = claim.recordId === undefined ? "—" : printable(claim.recordId, TOKEN_GRAMMAR, 80);
599
+ return `| \`${obligationId}\` | \`${outcome}\` | ${provider === "—" ? provider : `\`${provider}\``} | ${evidence === "—" ? evidence : `\`${evidence}\``} |`;
600
+ }
601
+
602
+ function renderSummary(input: SummaryInput, config: HarnessConfig | null): string {
603
+ const lines: string[] = [];
604
+ lines.push("## Delivery record verification");
605
+ lines.push("");
606
+ lines.push(input.ok ? "**Result: verified.**" : "**Result: blocked.** The pull request head does not carry a delivery record this gate accepts.");
607
+ lines.push("");
608
+ lines.push("| | |");
609
+ lines.push("| --- | --- |");
610
+ // The refused case names the claim it refused, not a policy it holds — the
611
+ // difference between "running under this policy" and "asked to, and was told
612
+ // no" is the whole content of the cell.
613
+ const policyNote =
614
+ input.policyId === null
615
+ ? ""
616
+ : input.mode === "delegation-refused"
617
+ ? ` (refused claim \`${printable(input.policyId, ID_GRAMMAR)}\`)`
618
+ : ` (policy \`${printable(input.policyId, ID_GRAMMAR)}\`)`;
619
+ lines.push(`| Mode | \`${input.mode}\`${policyNote} |`);
620
+ // Omitted rather than filled with a placeholder when the run never got a head:
621
+ // "Verified commit [unprintable]" reads as a commit that could not be printed,
622
+ // when the truth is that nothing was verified at all.
623
+ if (input.headSha !== null) {
624
+ lines.push(
625
+ `| Verified commit | \`${printable(input.headSha, SHA_GRAMMAR)}\` (pull request head${input.headRef === null ? "" : `, \`${printable(input.headRef, TOKEN_GRAMMAR)}\``}) |`,
626
+ );
627
+ lines.push(
628
+ `| Not verified | \`${input.mergeRefSha === null ? "—" : printable(input.mergeRefSha, SHA_GRAMMAR)}\` (\`GITHUB_SHA\`, the synthetic merge commit) |`,
629
+ );
630
+ }
631
+ if (input.deliverableDigest !== null) {
632
+ lines.push(`| Deliverable identity | \`${printable(input.deliverableDigest, DIGEST_GRAMMAR)}\` (\`${printable(input.identityToken, TOKEN_GRAMMAR)}\`) |`);
633
+ }
634
+ if (input.recordPath !== null) lines.push(`| Delivery record | \`${input.recordPath}\` |`);
635
+ if (input.baseMovement !== null) {
636
+ const relaxed = input.check?.baseMovementRelaxed === true;
637
+ const relaxation = relaxed
638
+ ? ` — **relaxed** base movement: ${input.check?.relaxedDriftClasses.map((driftClass) => `\`${driftClass}\``).join(", ")}`
639
+ : "";
640
+ lines.push(`| Base-movement policy | \`${printable(input.baseMovement, TOKEN_GRAMMAR)}\`${relaxation} |`);
641
+ }
642
+ lines.push(`| Attestation | ${ATTESTATION_LABEL} |`);
643
+ lines.push("");
644
+
645
+ if (input.check !== null && config !== null && input.check.claims.length > 0) {
646
+ lines.push("### Claims");
647
+ lines.push("");
648
+ lines.push("| Obligation | Outcome | Provider | Evidence record |");
649
+ lines.push("| --- | --- | --- | --- |");
650
+ for (const claim of input.check.claims) lines.push(claimRow(claim, config));
651
+ lines.push("");
652
+ }
653
+
654
+ if (input.blockers.length > 0) {
655
+ const body = renderBlockers(input.blockers);
656
+ const fence = fenceFor(body);
657
+ lines.push("### Blockers");
658
+ lines.push("");
659
+ lines.push(`${fence}text`);
660
+ lines.push(body);
661
+ lines.push(fence);
662
+ lines.push("");
663
+ }
664
+
665
+ lines.push(
666
+ `_Attestation level is L0: ${ATTESTATION_LABEL}. This check proves that a gate ran against this exact deliverable and that the record has not gone stale — it does not prove who ran it._`,
667
+ );
668
+ return `${lines.join("\n")}\n`;
669
+ }
670
+
671
+ // ── The run ──────────────────────────────────────────────────────────────────
672
+
673
+ /**
674
+ * Verifies the tracked delivery record for one pull request event.
675
+ *
676
+ * Total: it never throws. Every failure — expected or not — becomes typed
677
+ * blockers, a rendered summary, and a non-zero exit code.
678
+ */
679
+ export async function runAction(runtime: ActionRuntime): Promise<ActionResult> {
680
+ const mergeRefSha = typeof runtime.env["GITHUB_SHA"] === "string" ? (runtime.env["GITHUB_SHA"] as string) : null;
681
+ let mode: ActionMode = "verify";
682
+ let headSha: string | null = null;
683
+ let headRef: string | null = null;
684
+ let policyId: string | null = null;
685
+ let deliverableDigest: string | null = null;
686
+ let identityToken: string | null = null;
687
+ let recordPath: string | null = null;
688
+ let baseMovement: string | null = null;
689
+ let check: DeliveryRecordCheck | null = null;
690
+ let config: HarnessConfig | null = null;
691
+ const blockers: Blocker[] = [];
692
+
693
+ const settle = async (): Promise<ActionResult> => {
694
+ const ok = blockers.length === 0;
695
+ const summary = renderSummary(
696
+ { ok, mode, policyId, headSha, headRef, mergeRefSha, deliverableDigest, identityToken, recordPath, check, baseMovement, blockers },
697
+ config,
698
+ );
699
+ try {
700
+ await runtime.writeSummary(summary);
701
+ } catch (error) {
702
+ // The summary is the product. A failure to publish it is reported on the
703
+ // log rather than swallowed, and never turns a blocked check into a pass.
704
+ runtime.log(`the check summary could not be published: ${error instanceof Error ? error.message : String(error)}`);
705
+ }
706
+ if (runtime.writeOutputs !== undefined) {
707
+ try {
708
+ await runtime.writeOutputs({
709
+ verified: String(ok),
710
+ mode,
711
+ "head-sha": headSha ?? "",
712
+ "record-path": recordPath ?? "",
713
+ "deliverable-digest": deliverableDigest ?? "",
714
+ });
715
+ } catch (error) {
716
+ runtime.log(`step outputs could not be written: ${error instanceof Error ? error.message : String(error)}`);
717
+ }
718
+ }
719
+ runtime.log(ok ? "delivery record verified against the pull request head" : "delivery record verification blocked");
720
+ return {
721
+ ok,
722
+ exitCode: ok ? ACTION_EXIT_OK : ACTION_EXIT_POLICY,
723
+ mode,
724
+ summary,
725
+ blockers,
726
+ headSha,
727
+ mergeRefSha,
728
+ recordPath,
729
+ deliverableDigest,
730
+ };
731
+ };
732
+
733
+ try {
734
+ const event = await resolvePullRequestEvent(runtime);
735
+ if (!event.ok) {
736
+ blockers.push(...event.blockers);
737
+ return await settle();
738
+ }
739
+ headSha = event.event.headSha;
740
+ headRef = event.event.headRef;
741
+
742
+ try {
743
+ config = await runtime.loadConfig(runtime.workspace);
744
+ } catch (error) {
745
+ if (error instanceof BlockedError) {
746
+ blockers.push(...error.blockers);
747
+ return await settle();
748
+ }
749
+ throw error;
750
+ }
751
+ baseMovement = config.deliveryRecordVerification.baseMovement;
752
+ identityToken = config.computingIdentityVersion;
753
+
754
+ const resolvedMode = resolveMode(runtime, config);
755
+ mode = resolvedMode.mode;
756
+ policyId = resolvedMode.policyId;
757
+ if (resolvedMode.blockers.length > 0) {
758
+ // A run that claimed authority it does not have stops here. Verifying
759
+ // anyway and reporting the result would let the claim's failure read as an
760
+ // aside on a check that otherwise passed.
761
+ blockers.push(...resolvedMode.blockers);
762
+ return await settle();
763
+ }
764
+
765
+ const git = gitPortFor(runtime);
766
+ // The repository root, so repo-relative paths resolve the same way whether
767
+ // the action runs at the root or under a `working-directory`.
768
+ const topLevel = await git(["rev-parse", "--show-toplevel"]);
769
+ const repoRoot = topLevel.exitCode === 0 ? topLevel.stdout.trim() : runtime.workspace;
770
+
771
+ // The head's tree, resolved from the head sha in the payload. Never
772
+ // `GITHUB_SHA`, never `HEAD`, never the working directory.
773
+ const headTree = await git(["rev-parse", "--verify", `${headSha}^{tree}`]);
774
+ if (headTree.exitCode !== 0) {
775
+ blockers.push(
776
+ actionBlocker({
777
+ code: "head_commit_unavailable",
778
+ summary: "The pull request head commit is not present in this checkout.",
779
+ details: `${headSha}: ${headTree.stderr.trim() || "git rev-parse failed"}`,
780
+ remediations: [
781
+ {
782
+ id: "check-out-the-pull-request-head",
783
+ kind: "code_change",
784
+ summary: "Check out `github.event.pull_request.head.sha` (not the default merge ref) with `fetch-depth: 0`.",
785
+ },
786
+ ],
787
+ }),
788
+ );
789
+ return await settle();
790
+ }
791
+ const treeSha = headTree.stdout.trim();
792
+
793
+ try {
794
+ deliverableDigest = await computeDeliverableIdentity({ rootDir: runtime.workspace, treeSha, config }, { run: runtime.git });
795
+ } catch (error) {
796
+ if (error instanceof BlockedError) {
797
+ blockers.push(...error.blockers);
798
+ return await settle();
799
+ }
800
+ throw error;
801
+ }
802
+
803
+ const baseTip = await git(["rev-parse", "--verify", `${config.baseRef}^{commit}`]);
804
+ if (baseTip.exitCode !== 0) {
805
+ blockers.push(
806
+ actionBlocker({
807
+ code: "base_ref_unresolved",
808
+ summary: `The configured base ref ${JSON.stringify(printable(config.baseRef, TOKEN_GRAMMAR))} does not resolve in this checkout.`,
809
+ details: baseTip.stderr.trim() || "git rev-parse failed",
810
+ remediations: [
811
+ {
812
+ id: "fetch-the-base-ref",
813
+ kind: "code_change",
814
+ summary: "Fetch the base ref in the workflow (`fetch-depth: 0`), or declare a base ref the checkout resolves.",
815
+ },
816
+ ],
817
+ }),
818
+ );
819
+ return await settle();
820
+ }
821
+ const mergeBase = await git(["merge-base", config.baseRef, headSha]);
822
+ if (mergeBase.exitCode !== 0) {
823
+ blockers.push(
824
+ actionBlocker({
825
+ code: "merge_base_unavailable",
826
+ summary: "The merge base between the pull request head and the configured base ref could not be computed.",
827
+ details: mergeBase.stderr.trim() || "git merge-base failed",
828
+ remediations: [
829
+ {
830
+ id: "deepen-the-checkout",
831
+ kind: "code_change",
832
+ summary: "Deepen the checkout (`fetch-depth: 0`) so the merge base with the configured base ref is present.",
833
+ },
834
+ ],
835
+ }),
836
+ );
837
+ return await settle();
838
+ }
839
+ const base = { ref: config.baseRef, tipSha: baseTip.stdout.trim(), mergeBaseSha: mergeBase.stdout.trim() };
840
+
841
+ const discovered = await readTrackedRecords(git, headSha, config);
842
+ blockers.push(...discovered.blockers);
843
+
844
+ const identity = { deliverableDigest, identityToken: config.computingIdentityVersion };
845
+ const selected = selectDeliveryRecordForIdentity(discovered.files, identity);
846
+ const expectedPath = deliveryRecordPathFor(config, deliverableDigest);
847
+
848
+ if (selected === undefined) {
849
+ if (discovered.blockers.length > 0) {
850
+ // The unreadable records already explain why nothing was selected;
851
+ // adding "missing" on top would name a second cause that is not one.
852
+ return await settle();
853
+ }
854
+ if (discovered.trackedCount === 0) {
855
+ const exists = await workspaceHas(runtime, repoRoot, expectedPath);
856
+ blockers.push(
857
+ exists
858
+ ? actionBlocker({
859
+ code: "delivery_record_untracked",
860
+ summary: "A delivery record for this deliverable exists in the workspace but is not tracked in the pull request head.",
861
+ details: `${expectedPath} is present but absent from the head commit's tree; an untracked file is not evidence a reviewer can see.`,
862
+ remediations: [
863
+ {
864
+ id: "track-the-delivery-record",
865
+ kind: "command",
866
+ command: ["git", "add", expectedPath],
867
+ summary: "Track the delivery record and push it with the change it describes (`git add`, then commit).",
868
+ },
869
+ ],
870
+ })
871
+ : actionBlocker({
872
+ code: "delivery_record_missing",
873
+ summary: "The pull request head carries no delivery record.",
874
+ details: `expected ${expectedPath}`,
875
+ remediations: [RECORD_AND_COMMIT],
876
+ }),
877
+ );
878
+ return await settle();
879
+ }
880
+ blockers.push(
881
+ actionBlocker({
882
+ code: IDENTITY_DRIFT_CLASS,
883
+ summary: "No tracked delivery record describes the deliverable identity recomputed from the pull request head.",
884
+ details: [
885
+ `head identity ${deliverableDigest} (${config.computingIdentityVersion})`,
886
+ `expected record ${expectedPath}`,
887
+ `${discovered.trackedCount} tracked record(s) bind to: ${discovered.files
888
+ .map((entry) => printable(entry.record.candidateBinding.deliverableDigest, DIGEST_GRAMMAR))
889
+ .join(", ")}`,
890
+ ].join("\n"),
891
+ remediations: [RE_RUN_THE_LOOP, RECORD_AND_COMMIT],
892
+ }),
893
+ );
894
+ return await settle();
895
+ }
896
+
897
+ // NO SILENT TIE-BREAK.
898
+ //
899
+ // `selectDeliveryRecordForIdentity` sorts by path so its answer is
900
+ // deterministic, which is a property of the *function* and not a licence for
901
+ // the caller to treat first-by-name as authoritative. Two tracked files
902
+ // claiming one identity is not a choice to make: a record planted at a name
903
+ // that sorts early would win the tie and publish its own claims table while
904
+ // the honest record — the one at the path the digest derives — went
905
+ // unmentioned. And a lone record that binds the identity from some *other*
906
+ // name is not the file this config's naming rule produces, so accepting it
907
+ // would accept a record whose name and content were assembled separately.
908
+ //
909
+ // Both are refused before any claim is published, and neither can be reached
910
+ // by an honest loop: `record` writes exactly one file, at exactly this path.
911
+ const bindingRecords = discovered.files.filter(
912
+ (entry) =>
913
+ entry.record.candidateBinding.deliverableDigest === identity.deliverableDigest &&
914
+ entry.record.candidateBinding.identityToken === identity.identityToken,
915
+ );
916
+ if (bindingRecords.length > 1) {
917
+ blockers.push(
918
+ actionBlocker({
919
+ code: "ambiguous_delivery_records",
920
+ summary: "More than one tracked delivery record claims the deliverable identity recomputed from the head.",
921
+ details: [`expected exactly ${expectedPath}`, ...bindingRecords.map((entry) => `also ${entry.path}`)].join("\n"),
922
+ remediations: [
923
+ {
924
+ id: "remove-the-duplicate-records",
925
+ kind: "manual_action",
926
+ summary: "Leave exactly one delivery record for this deliverable — the one at its derived path — and remove the rest.",
927
+ },
928
+ ],
929
+ }),
930
+ );
931
+ return await settle();
932
+ }
933
+ if (selected.path !== expectedPath) {
934
+ blockers.push(
935
+ actionBlocker({
936
+ code: "delivery_record_path_unexpected",
937
+ summary: "The tracked delivery record binding this deliverable is not at the path its identity derives.",
938
+ details: `found ${selected.path}; the record for this deliverable is ${expectedPath}`,
939
+ remediations: [
940
+ {
941
+ id: "restore-the-derived-record-path",
942
+ kind: "manual_action",
943
+ summary: "Remove the renamed record and re-run `delivery-harness record`, which writes the record at its derived path.",
944
+ },
945
+ ],
946
+ }),
947
+ );
948
+ return await settle();
949
+ }
950
+
951
+ recordPath = selected.path;
952
+ check = verifyDeliveryRecord(config, selected.record, identity, base);
953
+ blockers.push(...check.blockers);
954
+ return await settle();
955
+ } catch (error) {
956
+ blockers.push(
957
+ createInternalErrorBlocker({
958
+ source: ACTION_SOURCE,
959
+ error,
960
+ reproduce: ["delivery-harness", "verify"],
961
+ }),
962
+ );
963
+ return await settle();
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Whether a repo-relative path exists on disk.
969
+ *
970
+ * Joined against the repository *root*, not against `runtime.workspace`: under a
971
+ * `working-directory` the two differ, and a record path is repo-relative by
972
+ * definition. Anchoring on the working directory would look for the record in a
973
+ * place it never lives and report "missing" where "untracked" was the truth.
974
+ */
975
+ async function workspaceHas(runtime: ActionRuntime, repoRoot: string, relativePath: string): Promise<boolean> {
976
+ const absolute = path.join(repoRoot, relativePath);
977
+ if (runtime.pathExists !== undefined) return runtime.pathExists(absolute);
978
+ try {
979
+ await access(absolute);
980
+ return true;
981
+ } catch {
982
+ return false;
983
+ }
984
+ }
985
+
986
+ // ── The default runtime ──────────────────────────────────────────────────────
987
+
988
+ /**
989
+ * Loads the consumer's `harness.config.ts` and validates it through the kernel.
990
+ *
991
+ * The Action carries its own loader rather than importing the CLI's: the Action
992
+ * is a wrapper over the *kernel*, and a dependency on the operator CLI would put
993
+ * seven commands and an interactive prompt behind a read-only check. The
994
+ * validation itself is the kernel's single implementation; only the import and
995
+ * the blocker's source id are local, so the two loaders cannot disagree about
996
+ * what a valid config is.
997
+ */
998
+ export async function importHarnessConfig(rootDir: string): Promise<HarnessConfig> {
999
+ const configPath = path.join(rootDir, "harness.config.ts");
1000
+ let loaded: unknown;
1001
+ try {
1002
+ const module = (await import(pathToFileURL(configPath).href)) as { default?: unknown };
1003
+ loaded = module.default;
1004
+ } catch (error) {
1005
+ throw new BlockedError([
1006
+ actionBlocker({
1007
+ code: "config_unloadable",
1008
+ summary: "The harness configuration could not be loaded.",
1009
+ details: `${configPath}: ${error instanceof Error ? error.message : String(error)}`,
1010
+ remediations: [
1011
+ { id: "provide-a-harness-config", kind: "manual_action", summary: "Provide a valid harness.config.ts at the repository root." },
1012
+ ],
1013
+ }),
1014
+ ]);
1015
+ }
1016
+ const validation = validateHarnessConfig(loaded);
1017
+ if (!validation.ok) throw new BlockedError(validation.blockers);
1018
+ return validation.config;
1019
+ }
1020
+
1021
+ export function defaultRuntime(): ActionRuntime {
1022
+ const workspace = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
1023
+ const summaryPath = process.env["GITHUB_STEP_SUMMARY"];
1024
+ const outputPath = process.env["GITHUB_OUTPUT"];
1025
+ return {
1026
+ env: process.env,
1027
+ workspace,
1028
+ git: runGitCommand,
1029
+ readFile: (absolutePath) => readFile(absolutePath, "utf8"),
1030
+ loadConfig: importHarnessConfig,
1031
+ writeSummary: async (markdown) => {
1032
+ if (summaryPath === undefined || summaryPath === "") {
1033
+ process.stdout.write(markdown);
1034
+ return;
1035
+ }
1036
+ await appendFile(summaryPath, markdown, "utf8");
1037
+ },
1038
+ writeOutputs: async (outputs) => {
1039
+ if (outputPath === undefined || outputPath === "") return;
1040
+ // The heredoc form: an output value is arbitrary text, and `key=value`
1041
+ // breaks the moment one contains a newline.
1042
+ const body = Object.entries(outputs)
1043
+ .map(([key, value]) => `${key}<<__DELIVERY_HARNESS_EOF__\n${value}\n__DELIVERY_HARNESS_EOF__\n`)
1044
+ .join("");
1045
+ await appendFile(outputPath, body, "utf8");
1046
+ },
1047
+ log: (line) => process.stdout.write(`${line}\n`),
1048
+ };
1049
+ }
1050
+
1051
+ /** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
1052
+ function canonicalEntryPath(entryPath: string): string {
1053
+ try {
1054
+ return realpathSync(entryPath);
1055
+ } catch {
1056
+ return entryPath;
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Whether this module is the entry the process was started with.
1062
+ *
1063
+ * argv and `import.meta.url` may spell the same file differently: argv is the
1064
+ * caller's spelling, and Node builds the module URL from the realpath by
1065
+ * default but from the caller's spelling under `--preserve-symlinks-main`. So
1066
+ * each side is canonicalized independently and the canonical forms compared:
1067
+ * a symlinked spelling matches its realpath whenever the link can be read
1068
+ * (`/tmp` → `/private/tmp` on macOS, a runner's action path, a pnpm workspace
1069
+ * link), and equal spellings still match when neither side resolves.
1070
+ *
1071
+ * What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
1072
+ * through, and the failing-exit-code floor below sits inside this guard, so an
1073
+ * under-match exits 0 in silence — the Action reporting success having
1074
+ * verified nothing. The floor cannot be hoisted above the guard: that would
1075
+ * stamp a failing exit code on every process that merely *imports* this
1076
+ * module. And a non-`file:` module href (a bundled or single-executable
1077
+ * build) never matches — such a build must invoke `main` explicitly.
1078
+ */
1079
+ export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
1080
+ if (argvEntry === undefined) return false;
1081
+ let modulePath: string;
1082
+ try {
1083
+ modulePath = fileURLToPath(moduleHref);
1084
+ } catch {
1085
+ return false;
1086
+ }
1087
+ return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
1088
+ }
1089
+
1090
+ export async function main(): Promise<number> {
1091
+ const result = await runAction(defaultRuntime());
1092
+ return result.exitCode;
1093
+ }
1094
+
1095
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
1096
+ // FAIL CLOSED BEFORE ANYTHING RUNS. Node's default exit code is 0, so any way
1097
+ // of leaving without a verdict — a promise that never settles, an event loop
1098
+ // that drains early — would report a passing check from a job that decided
1099
+ // nothing. Starting at a failure inverts that default.
1100
+ process.exitCode = ACTION_EXIT_POLICY;
1101
+ main()
1102
+ .then((code) => {
1103
+ process.exitCode = code;
1104
+ })
1105
+ .catch((error: unknown) => {
1106
+ process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
1107
+ process.exitCode = ACTION_EXIT_POLICY;
1108
+ });
1109
+ }