@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 +202 -0
- package/NOTICE +5 -0
- package/package.json +22 -0
- package/src/index.ts +24 -0
- package/src/main.ts +1109 -0
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
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
|
+
}
|