@ever-works/github-actions-build-plugin 1.0.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.
@@ -0,0 +1,1474 @@
1
+ import { BuildValue, JsonSchema, IBuildPlugin, PluginCategory, BuildStrategy, PluginContext, BuildAuth, PluginManifest, PrepareRepositoryInput, RepositoryWriter, PrepareRepositoryResult, StartBuildInput, BuildRef, BuildSnapshot, AppBuildBlock, RepositoryWriteErrorCode } from '@ever-works/plugin';
2
+ import { AppBuildBlockedReason, AppBuildKind, AppBuildRunnerClass, APP_BUILD_GENERATOR_VERSION } from '@ever-works/contracts';
3
+ import sodiumType from 'libsodium-wrappers';
4
+
5
+ /**
6
+ * APW-05 T12 — the narrow Actions API this plugin needs, and nothing else.
7
+ *
8
+ * ## Why a port, when `octokit` is already a dependency
9
+ *
10
+ * The same reason `RepositorySecretPort` is one (`repo/secret-sync.ts:58`): a
11
+ * fake is a few lines, so every rule in `run-correlator.ts` and
12
+ * `run-observer.ts` — the adoption window, the minutes rounding, "only the
13
+ * `build` job decides status" — is provable against recorded GitHub shapes
14
+ * without a socket, a token or a recorded-cassette harness. The port is the
15
+ * seam; {@link octokitActionsRunsPort} is the one implementation, and it is a
16
+ * thin call-through with no logic of its own to test.
17
+ *
18
+ * Octokit rather than hand-rolled `fetch`: it is the official SDK, it is already
19
+ * in this package's dependencies, and it handles the redirect-following that
20
+ * `GET actions/jobs/{id}/logs` needs (plan §4.8's logs tail).
21
+ *
22
+ * ## Shapes are the subset this plugin reads
23
+ *
24
+ * Each interface below names only the fields the correlator, the observer or the
25
+ * artifact reader actually use. That is deliberate: a wider type would invite a
26
+ * reader to assume a field is handled when nothing reads it, and GitHub's run
27
+ * payload is large enough that "the fields we use" is genuinely useful
28
+ * documentation.
29
+ */
30
+ /** One workflow run, as this plugin reads it (`GET /repos/{o}/{r}/actions/runs/{id}`). */
31
+ interface ActionsRun {
32
+ readonly id: number;
33
+ /** `queued` · `in_progress` · `completed` — GitHub's own vocabulary, mapped by the observer. */
34
+ readonly status: string | null;
35
+ readonly conclusion: string | null;
36
+ readonly run_attempt?: number | null;
37
+ /** What the generated workflow's `run-name:` produced — the correlation key (plan §4.8). */
38
+ readonly display_title?: string | null;
39
+ readonly name?: string | null;
40
+ readonly event?: string | null;
41
+ readonly head_branch?: string | null;
42
+ readonly head_sha?: string | null;
43
+ readonly html_url?: string | null;
44
+ readonly created_at?: string | null;
45
+ readonly run_started_at?: string | null;
46
+ readonly updated_at?: string | null;
47
+ readonly pull_requests?: ReadonlyArray<{
48
+ readonly number?: number | null;
49
+ }> | null;
50
+ }
51
+ /** One job of a run (`GET /repos/{o}/{r}/actions/runs/{id}/jobs`). */
52
+ interface ActionsJob {
53
+ readonly id: number;
54
+ readonly name: string;
55
+ readonly status?: string | null;
56
+ readonly conclusion?: string | null;
57
+ readonly started_at?: string | null;
58
+ readonly completed_at?: string | null;
59
+ readonly html_url?: string | null;
60
+ readonly labels?: readonly string[] | null;
61
+ readonly steps?: ReadonlyArray<{
62
+ readonly name?: string | null;
63
+ readonly number?: number | null;
64
+ readonly conclusion?: string | null;
65
+ }> | null;
66
+ }
67
+ /** One artifact of a run (`GET /repos/{o}/{r}/actions/runs/{id}/artifacts`). */
68
+ interface ActionsArtifact {
69
+ readonly id: number;
70
+ readonly name: string;
71
+ readonly size_in_bytes?: number | null;
72
+ readonly expired?: boolean | null;
73
+ }
74
+ /** The repository coordinates every call below takes. */
75
+ interface ActionsRepositoryRef {
76
+ readonly owner: string;
77
+ readonly repo: string;
78
+ }
79
+ /**
80
+ * The Actions operations APW-05 T12 needs.
81
+ *
82
+ * Every method may reject; the plugin turns a rejection into a throw, never into
83
+ * a `null` "no such run" — those are different answers and the contract
84
+ * (`IBuildPlugin.getBuild`) says so in its own docstring.
85
+ */
86
+ interface ActionsRunsPort {
87
+ /**
88
+ * `POST /repos/{o}/{r}/actions/workflows/{file}/dispatches` — 204, no body.
89
+ *
90
+ * GitHub answers a dispatch with no run id: the run is found afterwards, by
91
+ * `display_title`, which is what {@link ActionsRunsPort.listWorkflowRuns} and
92
+ * the correlator are for.
93
+ */
94
+ dispatchWorkflow(input: {
95
+ readonly repository: ActionsRepositoryRef;
96
+ readonly workflowFile: string;
97
+ readonly ref: string;
98
+ readonly inputs: Record<string, string>;
99
+ }): Promise<void>;
100
+ /** `GET /repos/{o}/{r}/actions/workflows/{file}/runs`, newest first. */
101
+ listWorkflowRuns(input: {
102
+ readonly repository: ActionsRepositoryRef;
103
+ readonly workflowFile: string;
104
+ readonly perPage: number;
105
+ readonly headSha?: string;
106
+ readonly event?: string;
107
+ }): Promise<readonly ActionsRun[]>;
108
+ /** `GET /repos/{o}/{r}/actions/runs/{id}`. `null` when GitHub answers 404. */
109
+ getWorkflowRun(input: {
110
+ readonly repository: ActionsRepositoryRef;
111
+ readonly runId: number;
112
+ }): Promise<ActionsRun | null>;
113
+ /** `GET /repos/{o}/{r}/actions/runs/{id}/jobs` (all attempts of the latest run attempt). */
114
+ listRunJobs(input: {
115
+ readonly repository: ActionsRepositoryRef;
116
+ readonly runId: number;
117
+ }): Promise<readonly ActionsJob[]>;
118
+ /** `POST /repos/{o}/{r}/actions/runs/{id}/cancel`. A run that is already finished is not an error. */
119
+ cancelWorkflowRun(input: {
120
+ readonly repository: ActionsRepositoryRef;
121
+ readonly runId: number;
122
+ }): Promise<void>;
123
+ /** `GET /repos/{o}/{r}/actions/runs/{id}/artifacts`. */
124
+ listRunArtifacts(input: {
125
+ readonly repository: ActionsRepositoryRef;
126
+ readonly runId: number;
127
+ }): Promise<readonly ActionsArtifact[]>;
128
+ /**
129
+ * `GET /repos/{o}/{r}/actions/artifacts/{id}/zip`, following the redirect.
130
+ *
131
+ * Answers the raw zip bytes. The caller enforces the size cap BEFORE
132
+ * unzipping (plan §4.8: "≤ 64 KB, refuse larger") — this port does not,
133
+ * because a port that silently truncated would make the cap untestable.
134
+ */
135
+ downloadArtifactZip(input: {
136
+ readonly repository: ActionsRepositoryRef;
137
+ readonly artifactId: number;
138
+ }): Promise<Uint8Array>;
139
+ /**
140
+ * `GET /repos/{o}/{r}/actions/jobs/{id}/logs`, following the redirect, asking
141
+ * for at most the last `maxBytes` with a `Range` header (plan §4.8).
142
+ *
143
+ * **Optional, and the only optional member of this port.** A caller whose
144
+ * token cannot read logs — or an adapter written before this landed — still
145
+ * gets a classified failure (`unknown`) rather than a thrown observation,
146
+ * which is why {@link readJobLogTail} probes for it instead of requiring it.
147
+ *
148
+ * `partial` reports whether the server honoured the range (`206`) rather than
149
+ * answering the whole body (`200`). The reader uses it to decide whether to
150
+ * drop the first line: a byte range almost never starts on a line boundary.
151
+ *
152
+ * `null` is "there is no log" — an expired one, or a job that never ran.
153
+ */
154
+ downloadJobLogTail?(input: {
155
+ readonly repository: ActionsRepositoryRef;
156
+ readonly jobId: number;
157
+ readonly maxBytes: number;
158
+ }): Promise<{
159
+ readonly bytes: Uint8Array;
160
+ readonly partial: boolean;
161
+ } | null>;
162
+ }
163
+
164
+ /** What {@link checkImageAccess} answers — the plugin's `ImageAccessResult`, plus nothing. */
165
+ interface GhcrAccessResult {
166
+ readonly visibility: 'public' | 'private' | 'unknown';
167
+ readonly readable: boolean;
168
+ readonly tokenScopesOk?: boolean;
169
+ readonly tokenExpiresAt?: string | null;
170
+ readonly digest?: string;
171
+ }
172
+ /**
173
+ * The one HTTP seam, so every rule here is provable without a socket.
174
+ *
175
+ * `json()` is read only from `ghcr.io/token`'s 200 answer — the one body this
176
+ * module needs; the real `fetch` `Response` satisfies the shape as it is.
177
+ */
178
+ type GhcrFetch = (url: string, init: {
179
+ readonly method: string;
180
+ readonly headers: Record<string, string>;
181
+ readonly redirect: 'manual';
182
+ }) => Promise<{
183
+ readonly status: number;
184
+ readonly headers: {
185
+ get(name: string): string | null;
186
+ };
187
+ json(): Promise<unknown>;
188
+ }>;
189
+
190
+ /**
191
+ * APW-05 T10 — build values in, sealed repository secrets out (plan §4.7, §4.10).
192
+ *
193
+ * ## What this file is, and what it is not
194
+ *
195
+ * It is the **only** place a build value becomes a repository secret, and the only
196
+ * place one is deleted. It is not a store: the value lives in the caller's memory,
197
+ * inside one sealed box, and in the `PUT` body — never in a return value, never in
198
+ * a log line, never in a thrown message. The three refusals below name things and
199
+ * counts, never values, because a refusal travels into `blockedReason` detail,
200
+ * Activity rows and telemetry (plan §9.1: "counters and identifiers only").
201
+ *
202
+ * ## The seals, and the sequence that must not drift
203
+ *
204
+ * `setActionSecret` in the github plugin
205
+ * (`packages/plugins/github/src/github-actions.service.ts:49-76`) is the pattern:
206
+ * validate the name, `await _sodium.ready`, `from_base64` the repository public
207
+ * key, `from_string` the value, `crypto_box_seal`, `to_base64`, `PUT` with
208
+ * `key_id`. This file repeats it exactly — the public key is fetched **once** for
209
+ * the whole batch, and every value is sealed with that one key before any `PUT`.
210
+ *
211
+ * The name rule is the same one the github plugin applies
212
+ * (`/^[A-Z_][A-Z0-9_]{0,254}$/`, never `GITHUB_`): GitHub itself rejects anything
213
+ * else, and a name that reaches GitHub is a name that already left the process.
214
+ * An invalid `EW_<NAME>` cannot come from APW-07's resolver (`^[A-Z_][A-Z0-9_]{0,127}$`
215
+ * env names), so it throws rather than blocking a Build — a programming error, not
216
+ * a user's mistake.
217
+ *
218
+ * ## The four refusals (plan §4.7:914–917, §4.10:1030–1033)
219
+ *
220
+ * | Condition | `blocked.reason` | detail |
221
+ * | ---------------------------------------------------------- | ----------------------- | ----------------- |
222
+ * | more than {@link APP_BUILD_MAX_VALUES} values | `tooManyBuildValues` | `{ count, max }` |
223
+ * | a value over {@link APP_BUILD_SECRET_MAX_BYTES} bytes | `buildValueTooLarge` | `{ name }` only |
224
+ * | an env name mapping onto `EW_VERIFY__PROMPTED` | `buildValueNameReserved`| `{ name }` |
225
+ * | GitHub answers the repository secret limit | `secretLimitReached` | `{}` |
226
+ *
227
+ * ## Removal is set arithmetic, and it is deliberately narrow
228
+ *
229
+ * The delete set is exactly `previouslyWrittenSecretNames` **minus** the names
230
+ * this call wrote. A name that was never written by the platform is never deleted,
231
+ * whatever it looks like — including one that starts with `EW_`, which is how an
232
+ * owner's own repository secret survives a preparation (FR-18).
233
+ *
234
+ * A DELETE that GitHub answers with 404 counts as removed: the name is already
235
+ * gone (an owner deleted it by hand, or an earlier preparation deleted it and
236
+ * failed before its row was written), and FR-18 is unaffected because the delete
237
+ * set is still only names the platform wrote. Throwing instead would leave the
238
+ * name on the row and fail every later preparation on the same 404, forever. A
239
+ * 404 from the public-key fetch or a `PUT` is NOT tolerated — there it means the
240
+ * repository itself is gone or not visible.
241
+ */
242
+ /** The three repository-secret operations this sync needs, and nothing else (a fake is four lines). */
243
+ interface RepositorySecretPort {
244
+ /** The repository's Actions public key — fetched once per operation (plan §4.7:916). */
245
+ getRepoPublicKey(): Promise<{
246
+ readonly key_id: string;
247
+ readonly key: string;
248
+ }>;
249
+ putRepoSecret(input: {
250
+ readonly name: string;
251
+ readonly encryptedValue: string;
252
+ readonly keyId: string;
253
+ }): Promise<void>;
254
+ deleteRepoSecret(input: {
255
+ readonly name: string;
256
+ }): Promise<void>;
257
+ }
258
+ /** Names and counts only. A logger that receives a value is a leak, and the spec proves it does not. */
259
+ interface SecretSyncLogger {
260
+ debug?(message: string, meta?: Record<string, unknown>): void;
261
+ info?(message: string, meta?: Record<string, unknown>): void;
262
+ warn?(message: string, meta?: Record<string, unknown>): void;
263
+ error?(message: string, meta?: Record<string, unknown>): void;
264
+ }
265
+ /** What one build-value sync did. */
266
+ interface BuildValueSyncResult {
267
+ /** The `EW_<NAME>` secrets written, in the order they were written. */
268
+ readonly secretsWritten: readonly string[];
269
+ /** The previously written names deleted, because nothing references them any more. */
270
+ readonly secretsRemoved: readonly string[];
271
+ /** `sha256(sorted (name, fingerprint))` over the values handed in (plan §4.7:918–919). */
272
+ readonly buildInputsHash: string;
273
+ readonly blocked?: {
274
+ readonly reason: AppBuildBlockedReason;
275
+ readonly detail: Record<string, string | number | string[]>;
276
+ };
277
+ }
278
+ /** What the sync is called with. */
279
+ interface BuildValueSyncInput {
280
+ readonly values: readonly BuildValue[];
281
+ /** The `EW_` names the platform wrote earlier and may now delete (plan §4.7:916–917). */
282
+ readonly previouslyWrittenSecretNames: readonly string[];
283
+ }
284
+ /** The result of writing §4.10's per-verification prompted-value secret. */
285
+ interface VerifyPromptedSecretResult {
286
+ readonly name: string;
287
+ /** The JSON payload's size in bytes — the same 48,000-byte ceiling every other value has. */
288
+ readonly bytes: number;
289
+ readonly blocked?: {
290
+ readonly reason: AppBuildBlockedReason;
291
+ readonly detail: Record<string, string | number | string[]>;
292
+ };
293
+ }
294
+ /** The factory's dependencies. */
295
+ interface SecretSyncDependencies {
296
+ readonly port: RepositorySecretPort;
297
+ readonly logger?: SecretSyncLogger;
298
+ /** Test seam for the seal. Defaults to {@link sealSecretValue} — the real libsodium sealed box. */
299
+ readonly seal?: (value: string, publicKey: string) => Promise<string>;
300
+ }
301
+ /** The operations §4.7 and §4.10 need, bound to one repository port. */
302
+ interface BuildValueSecretSync {
303
+ syncBuildValues(input: BuildValueSyncInput): Promise<BuildValueSyncResult>;
304
+ /** §4.10: the prompted values the owner set, as **one** JSON secret, just before dispatch. */
305
+ writeVerifyPromptedSecret(values: readonly BuildValue[]): Promise<VerifyPromptedSecretResult>;
306
+ /** §4.10: delete that secret by name, on the Build's terminal transition and in the sweep. */
307
+ deleteVerifyPromptedSecret(): Promise<{
308
+ readonly deleted: boolean;
309
+ }>;
310
+ }
311
+ /** GitHub's own secret-name rule, as the github plugin applies it (plan §4.7:914). */
312
+ declare const GITHUB_SECRET_NAME_PATTERN: RegExp;
313
+ /** True when GitHub would accept this secret name. */
314
+ declare function isValidGitHubSecretName(name: string): boolean;
315
+ /** The `EW_` name a build value is stored under. */
316
+ declare function buildSecretName(valueName: string): string;
317
+ declare function loadSodium(): Promise<typeof sodiumType>;
318
+ /**
319
+ * Seal one value with a repository public key — the sequence of
320
+ * `github-actions.service.ts:64-73`, unchanged.
321
+ */
322
+ declare function sealSecretValue(value: string, publicKey: string): Promise<string>;
323
+ /** GitHub's repository-secret limit answer, recognised without pinning a message GitHub may reword. */
324
+ declare function isSecretLimitError(error: unknown): boolean;
325
+ /**
326
+ * Build the §4.7/§4.10 operations over one repository port.
327
+ *
328
+ * Every operation fetches the public key once, at its own start: a key can rotate
329
+ * between a preparation and a verification, and a cached key would seal a value
330
+ * GitHub then refuses to decrypt.
331
+ */
332
+ declare function createBuildValueSecretSync(dependencies: SecretSyncDependencies): BuildValueSecretSync;
333
+
334
+ /**
335
+ * APW-05 T7 — the `github-actions-build` plugin settings (plan §4.4, setting by
336
+ * setting), and the one place the two platform-managed keys are declared.
337
+ *
338
+ * Every key, default, bound and marker below is transcribed from plan §4.4's
339
+ * table. Nothing is invented: no key appears that the table does not state, so a
340
+ * tightening of this schema cannot silently refuse a configuration the plan
341
+ * allows.
342
+ *
343
+ * ## What is load-bearing here, and why (pinned by
344
+ * `src/__tests__/plugin.manifest.spec.ts` rather than asserted in prose)
345
+ *
346
+ * - **`pullToken` is `x-secret`** (Constitution VII, plan §4.12). The marker is
347
+ * the only thing that makes the platform treat the value as a secret:
348
+ * `packages/plugin/src/api/api-response.types.ts` turns it into
349
+ * `secret: true` on the descriptor every API response is built from, and
350
+ * `plugin-operations.service.ts` masks exactly those fields. Drop the marker
351
+ * and the pull token becomes an ordinary setting, readable back out of the
352
+ * settings API.
353
+ * - **`pullToken` and `pullTokenExpiresAt` are `x-platformManaged`** (plan
354
+ * §4.4, `APW05-G07`): only `AppBuildPullTokenService.save` (APW-05 T29)
355
+ * writes them, through `PluginSettingsService.writePlatformManagedWorkSettings`,
356
+ * and the generic settings PATCH route must refuse both at every scope.
357
+ * - **no preparation key is declared** (`APW05-G03`). `workflowSha256`,
358
+ * `workflowPullRequestNumber`, `webhookId`, `runsEtag` and `repositoryBlock`
359
+ * are *preparation* state: their one home is the per-App-Work
360
+ * `work_build_preparations` row (plan §3.1b, landed in APW-05 T4/T5/T6), so
361
+ * a user cannot PATCH what only the platform may write. Declaring one here
362
+ * would put it back in reach of the settings API.
363
+ *
364
+ * ## `x-platformManaged` and the shared schema type
365
+ *
366
+ * `PluginSchemaExtensions` (`packages/plugin/src/settings/json-schema.types.ts`)
367
+ * does not declare `x-platformManaged`, and `PluginSettingsService.validateSettingsScope`
368
+ * (`packages/agent/src/plugins/services/plugin-settings.service.ts:812`) does not
369
+ * read it yet. Both files belong to other epics' slices, so the marker is
370
+ * declared **locally** by {@link PlatformManagedJsonSchema} — the narrowest
371
+ * annotation that lets plan §4.4's two markers exist in the schema today without
372
+ * a cast that would hide them. When the shared type grows the flag, this
373
+ * interface collapses to `JsonSchema` and nothing else moves. Until the service
374
+ * reads it, the marker is declared and asserted here while the refusal it
375
+ * enables is T29's (`APW05-G07`).
376
+ */
377
+ /**
378
+ * `JsonSchema` plus plan §4.4's platform-managed marker.
379
+ *
380
+ * A local widening, deliberately: the shared extension interface is owned by
381
+ * another slice (see the file docstring).
382
+ */
383
+ interface PlatformManagedJsonSchema extends JsonSchema {
384
+ /** Written by the platform only; the settings PATCH route refuses it at every scope (plan §4.4, `APW05-G07`). */
385
+ readonly 'x-platformManaged'?: boolean;
386
+ }
387
+ /** This plugin's settings schema — a {@link JsonSchema} whose properties may carry {@link PlatformManagedJsonSchema}. */
388
+ interface GitHubActionsBuildSettingsSchema extends JsonSchema {
389
+ readonly properties?: Record<string, JsonSchema | PlatformManagedJsonSchema>;
390
+ }
391
+ /** `largerRunnerLabel` (plan §4.4): private repositories only, when set. */
392
+ declare const LARGER_RUNNER_LABEL_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
393
+ /** Memory the platform reserves before a declared `memoryGiB` is allowed to fit (plan §4.4: `memory − 2`). */
394
+ declare const LARGER_RUNNER_MEMORY_MIN_GIB = 8;
395
+ /** Upper bound of `largerRunnerMemoryGiB` (plan §4.4). */
396
+ declare const LARGER_RUNNER_MEMORY_MAX_GIB = 256;
397
+ /** Lower bound of `largerRunnerVcpu` (plan §4.4). */
398
+ declare const LARGER_RUNNER_VCPU_MIN = 2;
399
+ /** Upper bound of `largerRunnerVcpu` (plan §4.4). */
400
+ declare const LARGER_RUNNER_VCPU_MAX = 64;
401
+ /**
402
+ * Every settings key this plugin declares, in plan §4.4's table order.
403
+ *
404
+ * Exported so the spec can assert the set, the order and the markers against one
405
+ * list instead of three hand-copied ones.
406
+ */
407
+ declare const GITHUB_ACTIONS_BUILD_SETTING_KEYS: readonly ["largerRunnerLabel", "largerRunnerMemoryGiB", "largerRunnerVcpu", "reclaimDisk", "attestations", "pullToken", "pullTokenExpiresAt", "allowBuildValuesOnPullRequests", "verificationPromptedValuesRequireApproval"];
408
+ /** A settings key of this plugin (plan §4.4). */
409
+ type GitHubActionsBuildSettingKey = (typeof GITHUB_ACTIONS_BUILD_SETTING_KEYS)[number];
410
+ /** The keys the platform writes and a user may never set (plan §4.4, `APW05-G07`). */
411
+ declare const GITHUB_ACTIONS_BUILD_PLATFORM_MANAGED_KEYS: readonly ["pullToken", "pullTokenExpiresAt"];
412
+ /**
413
+ * The preparation keys of plan §3.1b that this schema must NOT declare
414
+ * (`APW05-G03`). Named here so the negative assertion has one authority, and so
415
+ * the collision with the `work_build_preparations` columns is visible from the
416
+ * plugin side too.
417
+ */
418
+ declare const PREPARATION_STATE_KEYS: readonly ["workflowSha256", "workflowPullRequestNumber", "webhookId", "runsEtag", "repositoryBlock"];
419
+ /** The defaults plan §4.4 gives the four booleans and the two runner numbers. */
420
+ declare const GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS: {
421
+ readonly largerRunnerLabel: "";
422
+ readonly largerRunnerMemoryGiB: 0;
423
+ readonly largerRunnerVcpu: 0;
424
+ readonly reclaimDisk: true;
425
+ readonly attestations: false;
426
+ readonly allowBuildValuesOnPullRequests: false;
427
+ readonly verificationPromptedValuesRequireApproval: true;
428
+ };
429
+ /**
430
+ * The settings this plugin reads once resolved (plan §4.4).
431
+ *
432
+ * `pullToken`/`pullTokenExpiresAt` are optional: they exist only after
433
+ * `AppBuildPullTokenService` has written them (plan §4.12).
434
+ */
435
+ interface GitHubActionsBuildSettings {
436
+ largerRunnerLabel?: string;
437
+ largerRunnerMemoryGiB?: number;
438
+ largerRunnerVcpu?: number;
439
+ reclaimDisk?: boolean;
440
+ attestations?: boolean;
441
+ pullToken?: string;
442
+ pullTokenExpiresAt?: string;
443
+ allowBuildValuesOnPullRequests?: boolean;
444
+ verificationPromptedValuesRequireApproval?: boolean;
445
+ }
446
+ /**
447
+ * Plan §4.4's table as a JSON Schema.
448
+ *
449
+ * Scope, resolved from the table's own statements rather than from its summary
450
+ * sentence, which pulls in two directions: the notes mark **Work scope only**
451
+ * for `allowBuildValuesOnPullRequests` and `verificationPromptedValuesRequireApproval`
452
+ * explicitly, and plan §4.4:799 says "Work scope only for `pullToken`,
453
+ * `largerRunner*`, `attestations`; user and admin scope may set defaults for
454
+ * `reclaimDisk` and `largerRunner*`". Both cannot hold for `largerRunner*`, so
455
+ * the schema follows the second half (a user/admin **default** that a Work
456
+ * overrides) and keeps `pullToken`, `pullTokenExpiresAt`, `attestations` and the
457
+ * two XC-01 booleans at `x-scope: 'work'`, which is what the platform's scope
458
+ * model can express. Recorded for T11, which owns the runner selector and reads
459
+ * these three keys.
460
+ */
461
+ declare const gitHubActionsBuildSettingsSchema: GitHubActionsBuildSettingsSchema;
462
+
463
+ /**
464
+ * APW-05 T7 — the `github-actions-build` plugin: the identity the platform's
465
+ * discovery path and the `build` capability need, the settings of plan §4.4, and
466
+ * the members T8–T10 have not reached yet.
467
+ *
468
+ * What exists here:
469
+ *
470
+ * - the manifest the `everworks.plugin` block in `package.json` also declares
471
+ * (they must agree — `plugin-manifest-validator.service.ts` extracts the
472
+ * block from disk and `plugin-class-validator.service.ts` compares the loaded
473
+ * class against it);
474
+ * - `settingsSchema` — plan §4.4, with `pullToken` `x-secret` and both
475
+ * platform-managed markers (`APW05-G07`);
476
+ * - the two lifecycle hooks the loader calls. Both are **prototype methods on
477
+ * purpose**: `PluginClassValidatorService.isPluginClass` accepts a class only
478
+ * when `onLoad` and `onUnload` sit on its prototype, so an arrow-function
479
+ * property would make this plugin undiscoverable.
480
+ *
481
+ * ## Every `IBuildPlugin` member is implemented (Constitution VI, Rule: a
482
+ * ## capability a plugin claims, it implements — no member pretends)
483
+ *
484
+ * **All five, as of 2026-09-22.** T12 did four on 2026-09-21; T11's runner
485
+ * selector landed the next day, T41's checks job turned out to be already
486
+ * written, and §4.6 could then be composed — so `prepareRepository` is the last
487
+ * one and the `notImplemented` list is empty.
488
+ *
489
+ * | Member | Detail |
490
+ * | --------------------- | ----------------------------------------------------------------------------------------------------------- |
491
+ * | `prepareRepository` | §4.6: select the runner (T11), generate the file (T8) with the checks job (T41), deliver it (T9), then sync the build values (T10) — in that order, so nothing reaches a member's repository until it is known the Build could run |
492
+ * | `startBuild` | dispatches the generated file on the TRACKED branch with `ew_build_id` / `ew_sha` / `ew_mode` and the two optional inputs, then correlates once |
493
+ * | `getBuild` | run + jobs → `BuildSnapshot` via `runs/run-observer.ts`, plus the result artifact for a finished run and, for a FAILED one, the log tail (T13) classified into `failure`. T43 fills `verification` |
494
+ * | `cancelBuild` | resolves the same run `getBuild` would, then the cancel endpoint |
495
+ * | `getLogsUrl` | the run's own page |
496
+ *
497
+ * What is deliberately still NOT done, so nobody reads the table above as more
498
+ * than it says:
499
+ *
500
+ * - the **404/422 retry after a bootstrap commit** (`APW05-G02`) — a
501
+ * just-written workflow file is briefly undispatchable;
502
+ * - a digest read from the artifact is reported `confirmed: false` **always**.
503
+ * Confirming it is `checkImageAccess` (T14, below) and the comparison belongs
504
+ * to the caller, `AppBuildsService.finalize` (plan §4.8). For a succeeded
505
+ * push or manual run the snapshot also carries `image.pushLogDigest`, the
506
+ * digest the build job's Push step logged (`runs/push-digest.ts`), which the
507
+ * caller weighs only when the registry cannot be read;
508
+ * - a failure whose log has EXPIRED classifies as `unknown`. GitHub deletes
509
+ * job logs on the repository's retention schedule, and a Build observed
510
+ * after that is genuinely unexplainable — reporting `unknown` is the honest
511
+ * answer, and re-running the Build is the only way to get a better one.
512
+ *
513
+ * `checkImageAccess?` (T14) IS declared now, because it does something: it reads
514
+ * the manifest through GHCR's token exchange (`ghcr.io/token`, then a `HEAD` with
515
+ * the registry-issued bearer) — anonymously, then with the pull token presented
516
+ * to `/token` as Basic credentials — and checks that token's scopes through
517
+ * `GET /user`. It is the confirming half of §4.8's "only ever confirmed, never
518
+ * believed" — `getBuild` still reports an artifact digest as `confirmed: false`
519
+ * always, and `AppBuildsService.finalize` does the comparison, because it is the
520
+ * only party holding both the Build row and the `pullToken` setting.
521
+ *
522
+ * `listRecentRuns?` (T12's run discovery, §7.4a) is still deliberately **not
523
+ * declared**: it is optional on the contract and a caller materialises the member
524
+ * before calling it, so declaring a throwing placeholder would buy nothing and
525
+ * would tell a future reader the capability is wired when it is not.
526
+ */
527
+ declare class GitHubActionsBuildPlugin implements IBuildPlugin {
528
+ readonly id = "github-actions-build";
529
+ readonly name = "GitHub Actions builds";
530
+ readonly version = "1.0.0";
531
+ /**
532
+ * Plan §4.3: the `build` category — appended to `PLUGIN_CATEGORIES` by APW-05
533
+ * T3 (`packages/plugin/src/contracts/plugin-manifest.types.ts`), so the
534
+ * loader's own `isPluginCategory` gate accepts this manifest.
535
+ */
536
+ readonly category: PluginCategory;
537
+ /** Plan §4.3: exactly one capability. `isBuildPlugin` resolves this plugin on it. */
538
+ readonly capabilities: readonly string[];
539
+ /** Plan §4.3. `AppBuildKind` — a closed union in `@ever-works/contracts`. */
540
+ readonly buildKind: AppBuildKind;
541
+ /**
542
+ * Plan §4.1/R-13: `dockerfile` only.
543
+ *
544
+ * `auto` is deliberately absent: the zero-config builder is a provider-internal
545
+ * choice this plugin does not have, so the strategy gate leaves that Build
546
+ * unstarted (`strategyNotSupported`) rather than half-built. `image` and `none`
547
+ * are absent for the same reason — they need no build at all, and the prepare
548
+ * runner handles them before a build plugin is asked (plan §7.2).
549
+ */
550
+ readonly supportedStrategies: readonly BuildStrategy[];
551
+ /** Work-scoped settings, like the other per-Work provider plugins. */
552
+ readonly configurationMode = "user-required";
553
+ /** Plan §4.4, exactly. */
554
+ readonly settingsSchema: JsonSchema;
555
+ onLoad(context: PluginContext): Promise<void>;
556
+ onUnload(): Promise<void>;
557
+ /**
558
+ * The Actions API this plugin talks to, bound per call from the caller's auth.
559
+ *
560
+ * `protected` and a method, not a constructor seam: the loader constructs a
561
+ * plugin with **no arguments** (`PluginLoaderService.loadPluginModule` —
562
+ * `new PluginClass()`), so anything a spec wants to replace has to be
563
+ * replaceable on the instance. A spec subclasses and overrides this; production
564
+ * gets Octokit, which is the official SDK and already this package's dependency.
565
+ *
566
+ * A fresh client per call, deliberately: `auth.token` is resolved per call by
567
+ * the facade (plan §4.1) and may be a different member's installation token
568
+ * from one call to the next. Caching one would be caching a credential.
569
+ */
570
+ protected actionsPort(auth: BuildAuth): ActionsRunsPort;
571
+ /** The clock, as a seam, so the correlation window is provable without waiting. */
572
+ protected now(): number;
573
+ getManifest(): PluginManifest;
574
+ /**
575
+ * APW-05 §4.6 — put the workflow (and the build values) where a Build can use them.
576
+ *
577
+ * Four steps, in this order, and the order is the point: nothing is written to a
578
+ * member's repository until it is known the Build could run at all.
579
+ *
580
+ * 1. **Select the runner** (T11). A declared memory the chosen runner cannot
581
+ * give is `runnerTooSmall`, and it BLOCKS here — before a commit, before a
582
+ * secret, before anything reaches GitHub. The alternative is writing a
583
+ * workflow that is guaranteed to be OOM-killed minutes into its first run.
584
+ * 2. **Generate the file** (T8), with the checks job (T41) when the spec
585
+ * declares checks and the verify job when the App Work can verify.
586
+ * 3. **Deliver it** (T9): unchanged, committed, or a pull request for a
587
+ * repository that is not ours to write into (R-4). A hand-edited file is
588
+ * `editedByHand` and is never overwritten.
589
+ * 4. **Sync the build values** (T10), and only then — a secret written for a
590
+ * workflow that was never delivered is a secret nobody will clean up.
591
+ *
592
+ * ## The two failure shapes, kept apart
593
+ *
594
+ * A `blocked` result is a Build that will not start and a member who is told
595
+ * why. A THROW is this plugin failing at its own job. `writeWorkflow`'s own
596
+ * `'blocked'` state is mapped onto `PrepareRepositoryResult.blocked` rather than
597
+ * passed through, because that state is the writer's and is not one of the five
598
+ * the contract's `workflow.state` allows — the writer's docstring says so, and
599
+ * storing it would put a value in the database that no reader understands.
600
+ *
601
+ * ## `contentSha256` is stored only after a matching read-back
602
+ *
603
+ * `storedWorkflowSha256` is `null` until the writer has PROVEN the tracked
604
+ * branch holds the generated bytes (FR-8). Reporting the hash of what we sent,
605
+ * rather than of what is there, is how a failed write becomes an unnoticed
606
+ * hand-edit detection failure on the next preparation.
607
+ */
608
+ prepareRepository(input: PrepareRepositoryInput, auth: BuildAuth, writer: RepositoryWriter): Promise<PrepareRepositoryResult>;
609
+ /**
610
+ * The repository-secret operations T10's sync needs, over Octokit.
611
+ *
612
+ * A seam for the same reason {@link actionsPort} is one: the loader constructs
613
+ * this class with no arguments, so a spec replaces it on the instance.
614
+ */
615
+ protected secretPort(auth: BuildAuth, repository: {
616
+ readonly owner: string;
617
+ readonly repo: string;
618
+ }): RepositorySecretPort;
619
+ /**
620
+ * APW-05 T12 — dispatch the generated workflow on the App Work's tracked branch.
621
+ *
622
+ * ## `providerRunId` is `null` here, and that is not a failure
623
+ *
624
+ * `POST .../dispatches` answers **204 with no body**: GitHub says nothing about
625
+ * the run it just created. So this method dispatches, records the instant, and
626
+ * makes ONE correlation attempt; the run is usually not listed that fast, and a
627
+ * `null` id is the normal answer. `app-build-watch` polls `getBuild`, which
628
+ * correlates again on every poll. Blocking here until a run appeared would hold
629
+ * the caller's request open for an arbitrary provider delay.
630
+ *
631
+ * ## The branch is the tracked branch, never the caller's ref
632
+ *
633
+ * FR-14: a Build may only run for the App Work's tracked branch. `input.ref` is
634
+ * what to BUILD; `repository.trackedBranch` is where the workflow file lives and
635
+ * the only `ref` a dispatch is allowed to name. Passing `input.ref` would let a
636
+ * Build run a workflow file from a branch nobody reviewed.
637
+ */
638
+ startBuild(input: StartBuildInput, auth: BuildAuth): Promise<{
639
+ providerRunId: string | null;
640
+ dispatchedAt: string;
641
+ }>;
642
+ /**
643
+ * APW-05 T12 — observe one run.
644
+ *
645
+ * Correlates first when the ref carries no `providerRunId`: a Build dispatched
646
+ * moments ago has none, and the watch job's whole purpose is to find it. `null`
647
+ * means "no such run" — never "I could not tell", which the contract says is a
648
+ * throw, and which is why a failed API call is not caught here.
649
+ *
650
+ * `redact` is APW-07's redactor for this App Work, and it is USED: every line
651
+ * of every excerpt goes through it, and through the secret-shape mask after
652
+ * it, before it leaves this method (FR-38).
653
+ */
654
+ getBuild(ref: BuildRef, auth: BuildAuth, redact: (text: string) => string): Promise<BuildSnapshot | null>;
655
+ /**
656
+ * APW-05 T13 — why a failed run failed, as `BuildSnapshot.failure`.
657
+ *
658
+ * ## Which job's log
659
+ *
660
+ * The `build` job decides the Build's status (`run-observer.ts`, R-9), so when
661
+ * it failed it is the one worth reading. Only when it did NOT fail — a Build
662
+ * the run marked failed for some other reason — does any other failing job
663
+ * stand in. A failed `Ever Works check:` job is deliberately last: it never
664
+ * flips a Build to `failed`, so if it is the only failure the run's own
665
+ * conclusion is what actually needs explaining.
666
+ *
667
+ * No failing job at all is not an error: `jobCount` then carries the run into
668
+ * the classifier's `workflowInvalid` row, which is exactly what a run that
669
+ * produced no jobs is.
670
+ *
671
+ * ## Why the artifact's smoke rows are passed in
672
+ *
673
+ * `verificationFailed` is a class the LOG cannot show: the smoke rows live in
674
+ * the result artifact, which `getBuild` has already read by this point. Passing
675
+ * them costs nothing and is the difference between "your build failed" and
676
+ * "your app built, and then did not answer".
677
+ */
678
+ private classifyRunFailure;
679
+ /**
680
+ * APW-05 T14 remainder — the digest the build job's `Push` step logged for
681
+ * this Build's `sha-<sha>` tag, or `undefined`.
682
+ *
683
+ * The BUILD job's log, because that is the job holding the Push step; read as
684
+ * the same bounded tail T13 reads (the Push step is near the end of the job,
685
+ * followed only by the result write and the upload). An unreadable or expired
686
+ * log is `''` from `readJobLogTail`, so this never fails an observation — it
687
+ * only costs the fallback. Which lines count is `runs/push-digest.ts`'s rule.
688
+ */
689
+ private readPushLogDigest;
690
+ /**
691
+ * The run this ref addresses: the one it names, or the one correlation finds.
692
+ *
693
+ * Shared by `getBuild`, `cancelBuild` and `getLogsUrl` so all three agree about
694
+ * which run a ref means — a cancel that addressed a different run from the
695
+ * observation would stop the wrong Build.
696
+ */
697
+ private resolveRunId;
698
+ /**
699
+ * APW-05 T12 — stop the run.
700
+ *
701
+ * A ref that resolves to no run is a no-op rather than a throw: the caller asked
702
+ * for the Build to stop, and a Build with no run has stopped. The port swallows
703
+ * GitHub's 409 ("not cancellable") for the same reason — a finished run is not a
704
+ * failed cancel.
705
+ */
706
+ cancelBuild(ref: BuildRef, auth: BuildAuth): Promise<void>;
707
+ /**
708
+ * APW-05 T14 — can this installation read the image, and is its pull token the
709
+ * right shape?
710
+ *
711
+ * Declared now that it does something. It stayed undeclared while it did not,
712
+ * because the contract makes it optional and a caller materialises the member
713
+ * before calling it — so a throwing placeholder would have told a reader the
714
+ * capability was wired when it was not.
715
+ *
716
+ * This is the **confirming** half of plan §4.8's "only ever confirmed, never
717
+ * believed". `getBuild` reports the digest the member's own CI claimed with
718
+ * `confirmed: false`, always; comparing it against the digest this method reads
719
+ * from the registry is what makes it true, and that comparison belongs to the
720
+ * caller — `AppBuildsService.finalize` — which is the only party holding both
721
+ * the Build row and the installation's `pullToken` setting (`getBuild(ref, auth)`
722
+ * is given neither).
723
+ *
724
+ * The registry is read through GHCR's own token exchange (`registry/ghcr-access.ts`):
725
+ * a bare manifest `HEAD` is 401 for every image on GHCR, public or not, so a
726
+ * public image is only ever seen as public through the anonymous registry token.
727
+ * The pull token reaches `ghcr.io/token` (as Basic) and `api.github.com/user`,
728
+ * and nothing else.
729
+ */
730
+ checkImageAccess(input: {
731
+ readonly imageRepository: string;
732
+ readonly tag: string;
733
+ readonly pullToken?: string;
734
+ }): Promise<GhcrAccessResult>;
735
+ /**
736
+ * The registry/API HTTP seam.
737
+ *
738
+ * `redirect: 'manual'` is set by `ghcr-access.ts` on every request and passed
739
+ * straight through here: a 3xx followed with the `Authorization` header still
740
+ * attached is how a pull token reaches a host that is neither `api.github.com`
741
+ * nor `ghcr.io`, and that module's spec watches every call for exactly that.
742
+ */
743
+ protected registryFetch(): GhcrFetch;
744
+ /** APW-05 T12 — the run's own page, which is where a member reads its logs. */
745
+ getLogsUrl(ref: BuildRef, auth: BuildAuth): Promise<string | null>;
746
+ }
747
+ /**
748
+ * The error every unimplemented member throws.
749
+ *
750
+ * One shape and one message format, so a caller can tell "this plugin does not
751
+ * do that yet" from a provider failure — and so the message names the task that
752
+ * will make it true rather than a bare "not implemented".
753
+ */
754
+ declare function notImplemented(member: string, owner: string): Error;
755
+
756
+ /**
757
+ * APW-05 T8 — the third-party actions the generated workflow is allowed to use,
758
+ * pinned to a commit.
759
+ *
760
+ * Plan §4.5: "`ACTION_PINS` values are 40-character commit hashes with the
761
+ * release tag in a trailing comment; a unit test fails when any pin is not 40 hex
762
+ * characters. Bumping pins bumps `inputsHash`, so every App Work gets a pull
763
+ * request or commit with the new file on its next preparation."
764
+ *
765
+ * ## How these five were resolved (and how to bump them)
766
+ *
767
+ * Each `sha` is the **commit** a release tag pointed at when this file was
768
+ * written, read with `git ls-remote --tags https://github.com/<owner>/<repo>`,
769
+ * peeling annotated tags (`^{}`) so a tag object's hash can never be mistaken for
770
+ * a commit. `resolvedOn` records the day. A bump is: re-run that command, take
771
+ * the newest stable tag's commit, update `sha` + `tag` + `resolvedOn` — nothing
772
+ * else changes, and `inputsHash` moves on its own.
773
+ *
774
+ * The five names are exactly the plan §4.3 generator's set. A sixth action is
775
+ * not "just another entry": §4.5's pin set is what the golden files pin, so a new
776
+ * action is a new decision about what runs inside a customer's repository.
777
+ */
778
+ interface ActionPin {
779
+ /** `owner/repo`, as it appears before the `@` in a `uses:` line. */
780
+ readonly action: string;
781
+ /** The release tag this commit was the tip of, for the trailing comment. */
782
+ readonly tag: string;
783
+ /** The 40-character commit hash that must appear after the `@`. */
784
+ readonly sha: string;
785
+ /** The day `sha` was resolved from the tag. */
786
+ readonly resolvedOn: string;
787
+ }
788
+ declare const ACTION_PINS: {
789
+ /** `actions/checkout@v7.0.1`. */
790
+ readonly checkout: {
791
+ readonly action: "actions/checkout";
792
+ readonly tag: "v7.0.1";
793
+ readonly sha: "3d3c42e5aac5ba805825da76410c181273ba90b1";
794
+ readonly resolvedOn: "2026-09-18";
795
+ };
796
+ /** `docker/setup-buildx-action@v4.4.1`. */
797
+ readonly setupBuildx: {
798
+ readonly action: "docker/setup-buildx-action";
799
+ readonly tag: "v4.4.1";
800
+ readonly sha: "f87e5991a6d7451dcb8d9637bfbc97413f497069";
801
+ readonly resolvedOn: "2026-09-18";
802
+ };
803
+ /** `docker/login-action@v4.6.0`. */
804
+ readonly login: {
805
+ readonly action: "docker/login-action";
806
+ readonly tag: "v4.6.0";
807
+ readonly sha: "dbcb813823bdd20940b903addbd779551569679f";
808
+ readonly resolvedOn: "2026-09-18";
809
+ };
810
+ /** `docker/build-push-action@v7.4.0`. */
811
+ readonly buildPush: {
812
+ readonly action: "docker/build-push-action";
813
+ readonly tag: "v7.4.0";
814
+ readonly sha: "c3c9e263c25d99ce0380d002d59b67737d91b0dc";
815
+ readonly resolvedOn: "2026-09-18";
816
+ };
817
+ /** `actions/upload-artifact@v7.0.1`. */
818
+ readonly uploadArtifact: {
819
+ readonly action: "actions/upload-artifact";
820
+ readonly tag: "v7.0.1";
821
+ readonly sha: "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
822
+ readonly resolvedOn: "2026-09-18";
823
+ };
824
+ };
825
+ /** The keys of {@link ACTION_PINS}, in declaration order. */
826
+ type ActionPinKey = keyof typeof ACTION_PINS;
827
+ /** A commit hash, and nothing else — plan §4.5's "40 hex characters" (ACC-05-05). */
828
+ declare const ACTION_PIN_SHA_PATTERN: RegExp;
829
+ /** `owner/repo@<40 hex> # <tag>` — the exact `uses:` value, comment included. */
830
+ declare function actionPin(pin: ActionPin): string;
831
+ /** `owner/repo@<40 hex>` — the pin without its trailing comment, for a canonical input. */
832
+ declare function actionPinReference(pin: ActionPin): string;
833
+ /**
834
+ * The pin set in canonical form: key → `owner/repo@<40 hex>`, keys sorted.
835
+ *
836
+ * This is what `inputs-hash.ts` hashes, so a bump moves `inputsHash` (and
837
+ * `resolvedOn` deliberately does **not**: it records when the hash was read, not
838
+ * what will run).
839
+ */
840
+ declare function canonicalActionPins(): Record<string, string>;
841
+ /**
842
+ * Names the generated workflow must never reference in clear text.
843
+ *
844
+ * Exported for the generator's own guard rather than for a caller: the
845
+ * restricted pull-request literal and the check-name prefix are the two strings
846
+ * the golden and generator specs assert against each other.
847
+ */
848
+ declare const GENERATOR_RESERVED_LITERALS: readonly ["ew-restricted", "Ever Works check: "];
849
+
850
+ /**
851
+ * APW-05 T8 — the canonical inputs of a generated workflow and their fingerprint.
852
+ *
853
+ * Plan §4.5: "**Canonical inputs**: `{ generator: 1, trackedBranch, build
854
+ * (normalised: defaults applied, keys sorted), values: names + secret +
855
+ * fromBuildService (never values), runner: { label, class }, settings: {
856
+ * reclaimDisk, attestations }, pins: ACTION_PINS, verifyEnabled, checks: [{ name,
857
+ * required, timeoutMinutes, commandSha256 }] }`. `inputsHash = sha256(canonical
858
+ * JSON)`."
859
+ *
860
+ * ## Why this canonical set is wider than the plan's line, deliberately
861
+ *
862
+ * The line above was written before `XC-01` (2026-09-17) added
863
+ * `allowBuildValuesOnPullRequests`, and it never mentioned the repository, the
864
+ * App spec hash, the bootstrap flag or the embedded verify-runner script — all of
865
+ * which change the generated **bytes**. A fingerprint that stays still while the
866
+ * bytes move is worse than no fingerprint: plan §4.5 relies on it to decide
867
+ * whether an App Work needs a new commit or pull request ("Bumping pins bumps
868
+ * `inputsHash`, so every App Work gets a pull request or commit with the new file
869
+ * on its next preparation"), and §2.4's header line publishes it as the file's
870
+ * identity. So every field below is either the plan's own or one that changes the
871
+ * output, and {@link CANONICAL_INPUT_FIELDS} lists them in one place.
872
+ *
873
+ * `values` keeps the plan's rule exactly: **names, `secret` and
874
+ * `fromBuildService` only — never a value, never a fingerprint.** Two App Works
875
+ * whose `DATABASE_URL` is the same env entry name with different stored values
876
+ * have the same workflow bytes, and that is the point: rotating a secret must not
877
+ * rewrite a workflow file.
878
+ *
879
+ * The JSON form is fixed here rather than left to `JSON.stringify`'s insertion
880
+ * order: keys are written in {@link CANONICAL_INPUT_FIELDS} order, arrays that
881
+ * the caller may hand in unordered (values, build args, services, checks) are
882
+ * sorted by their own key, and the result contains no whitespace — so the same
883
+ * inputs hash the same on every platform (ACC-05-03).
884
+ */
885
+ /** One build service, canonicalised (plan §2.4 `services`, §4.5 `BUILD_SERVICE_DEFAULTS`). */
886
+ interface CanonicalBuildService {
887
+ readonly name: string;
888
+ readonly image: string;
889
+ /** The declared published port, when the App spec gives one. */
890
+ readonly port?: number;
891
+ /** The declared `env`, sorted by name — the image defaults are applied at emission, not here. */
892
+ readonly env: ReadonlyArray<{
893
+ readonly name: string;
894
+ readonly value: string;
895
+ }>;
896
+ }
897
+ /** One build argument, canonicalised (plan §2.4 `build-args`). */
898
+ interface CanonicalBuildArg {
899
+ readonly name: string;
900
+ /** A literal value, as written in the App spec. */
901
+ readonly value?: string;
902
+ /** The build-value name this argument reads from the repository secrets. */
903
+ readonly fromEnv?: string;
904
+ }
905
+ /** The App spec's `build` block, normalised (plan §4.5: "defaults applied, keys sorted"). */
906
+ interface CanonicalBuildBlock {
907
+ readonly strategy: string;
908
+ /** `build.context`, defaulted to `.` — plan §2.4 `context: <build.context>`. */
909
+ readonly context: string;
910
+ /** `build.dockerfile`, defaulted to `Dockerfile`, relative to the repository root. */
911
+ readonly dockerfile: string;
912
+ readonly target?: string;
913
+ readonly args: readonly CanonicalBuildArg[];
914
+ readonly services: readonly CanonicalBuildService[];
915
+ readonly resources: {
916
+ readonly cpu: number;
917
+ readonly memoryGiB?: number;
918
+ readonly timeoutMinutes: number;
919
+ };
920
+ }
921
+ /** One synced build value's contribution — plan §4.5: "names + secret + fromBuildService (never values)". */
922
+ interface CanonicalBuildValue {
923
+ readonly name: string;
924
+ readonly secret: boolean;
925
+ readonly fromBuildService: boolean;
926
+ }
927
+ /** One App spec check (R-9, plan §4.5) — the command travels as its digest. */
928
+ interface CanonicalCheck {
929
+ readonly name: string;
930
+ readonly required: boolean;
931
+ /** `ceil(timeoutSeconds / 60)` — the plan's unit. */
932
+ readonly timeoutMinutes: number;
933
+ readonly commandSha256: string;
934
+ }
935
+ /** The runner the workflow will ask for (plan §4.5 `runner: { label, class }`). */
936
+ interface CanonicalRunner {
937
+ readonly label: string;
938
+ readonly class: AppBuildRunnerClass;
939
+ }
940
+ /** The two settings that change the emitted bytes (plan §4.4, §4.5) plus XC-01's opt-out. */
941
+ interface CanonicalSettings {
942
+ readonly reclaimDisk: boolean;
943
+ /** The **effective** value: `settings.attestations === true && repository.visibility === 'public'` (plan §4.5, `APW05-G07`). */
944
+ readonly attestations: boolean;
945
+ readonly allowBuildValuesOnPullRequests: boolean;
946
+ readonly verificationPromptedValuesRequireApproval: boolean;
947
+ }
948
+ /** Everything the fingerprint covers, in the order the canonical JSON writes it. */
949
+ interface CanonicalWorkflowInputs {
950
+ readonly generator: typeof APP_BUILD_GENERATOR_VERSION;
951
+ readonly bootstrap: boolean;
952
+ readonly trackedBranch: string;
953
+ readonly repository: {
954
+ readonly owner: string;
955
+ readonly repo: string;
956
+ readonly visibility: 'public' | 'private';
957
+ };
958
+ readonly appSpecHash: string;
959
+ /** `null` for the bootstrap file of plan §4.6 step 0, which carries no build block. */
960
+ readonly build: CanonicalBuildBlock | null;
961
+ readonly values: readonly CanonicalBuildValue[];
962
+ readonly runner: CanonicalRunner;
963
+ readonly settings: CanonicalSettings;
964
+ readonly pins: Record<string, string>;
965
+ readonly verifyEnabled: boolean;
966
+ readonly checks: readonly CanonicalCheck[];
967
+ /** sha256 of the embedded verify-runner script — a T15 change must move the file. */
968
+ readonly verifyRunnerScriptSha256: string;
969
+ }
970
+ /**
971
+ * The canonical key order, as data.
972
+ *
973
+ * `canonicalJson` writes exactly these keys in this order; the spec asserts that
974
+ * this list and the emitted header agree, so adding a field cannot half-land.
975
+ */
976
+ declare const CANONICAL_INPUT_FIELDS: readonly ["generator", "bootstrap", "trackedBranch", "repository", "appSpecHash", "build", "values", "runner", "settings", "pins", "verifyEnabled", "checks", "verifyRunnerScriptSha256"];
977
+ /** `ceil(timeoutSeconds / 60)` — the plan's minutes unit for a check (R-9, plan §4.5). */
978
+ declare function checkTimeoutMinutes(timeoutSeconds: number): number;
979
+ /** A check as the canonical inputs carry it — the command only as its digest. */
980
+ declare function canonicalCheck(check: {
981
+ readonly name: string;
982
+ readonly command: string;
983
+ readonly required: boolean;
984
+ readonly timeoutSeconds: number;
985
+ }): CanonicalCheck;
986
+ /**
987
+ * Sort the parts a caller may hand in unordered, so the canonical JSON — and the
988
+ * hash — is order-independent (ACC-05-03).
989
+ *
990
+ * `values` by name (then `secret`, then `fromBuildService`, so two entries that
991
+ * somehow share a name still order deterministically); build args by name;
992
+ * services by name; checks by name.
993
+ */
994
+ declare function canonicaliseWorkflowInputs(inputs: CanonicalWorkflowInputs): CanonicalWorkflowInputs;
995
+ /** The canonical JSON of {@link CANONICAL_INPUT_FIELDS}, no whitespace, fixed key order. */
996
+ declare function canonicalWorkflowJson(inputs: CanonicalWorkflowInputs): string;
997
+ /** `sha256:<64 hex>` over {@link canonicalWorkflowJson} — the header line's fingerprint (plan §2.4, §4.5). */
998
+ declare function computeWorkflowInputsHash(inputs: CanonicalWorkflowInputs): string;
999
+
1000
+ /**
1001
+ * APW-05 T8 — canonical inputs in, the workflow file out (plan §2.4, §4.5).
1002
+ *
1003
+ * The file this produces is the platform's, not the user's: it is written to the
1004
+ * tracked branch, hand edits are never overwritten (plan §4.6 step 5, T9), and
1005
+ * every value it needs travels as a repository secret (`EW_<NAME>`, plan §4.7,
1006
+ * T10) rather than as workflow text. Five properties are load-bearing and are
1007
+ * pinned by `src/__tests__/generator.spec.ts` against the ten goldens under
1008
+ * `src/__tests__/golden/`:
1009
+ *
1010
+ * 1. **Byte-stable.** Plan §4.5: a line-array builder plus {@link yamlString},
1011
+ * LF endings, one trailing newline, and every unordered input sorted before
1012
+ * it is hashed or emitted — the same inputs yield identical bytes on every
1013
+ * platform (ACC-05-03).
1014
+ * 2. **No stored value ever appears.** A `fromEnv` argument is emitted as a
1015
+ * `secrets.EW_<NAME>` reference; the resolved value only ever exists inside
1016
+ * the job's environment and the sealed-box PUT (ACC-05-05). A literal
1017
+ * `value` argument *is* emitted verbatim — it is written in the App spec, not
1018
+ * resolved from a stored value, which is exactly the distinction plan §2.4's
1019
+ * two `build-args` lines draw.
1020
+ * 3. **A pull request from another repository runs nothing.** The `build` job's
1021
+ * `if:` requires the head repository to be the repository, and the only job
1022
+ * that references `secrets.` is that one (ACC-05-06).
1023
+ * 4. **`load: true`, `push: false`, then a separate `docker push`.** A secret
1024
+ * found in the image metadata is caught *before* a byte leaves the runner
1025
+ * (FR-21), which is why the secret check sits between the build and the push.
1026
+ * 5. **The `verify` job can neither publish an image nor write the cache**
1027
+ * (FR-54): `packages: read`, no `docker push`, no `--push`, no `cache-to`,
1028
+ * and its own `verify-<buildId>` concurrency group so it can never replace —
1029
+ * or be replaced by — the push Build of the head commit (`APW05-G02`).
1030
+ *
1031
+ * ## The `checks` job
1032
+ *
1033
+ * T41 landed it: the job is `src/workflow/checks-job.ts`'s (R-9, plan §2.4:276–308,
1034
+ * §4.14) and this file only decides **whether** it is emitted — after the `build`
1035
+ * job, which is where §2.4's job order puts it (build → verify → checks), and only
1036
+ * when the App spec declares at least one check and the file is not the bootstrap
1037
+ * file of §4.6 step 0 (which has no App spec to read checks from). `checks` also
1038
+ * travels into the canonical inputs, so the fingerprint covers a check-command
1039
+ * change as well as the bytes (plan §4.5: "A check command change therefore
1040
+ * changes the file and its fingerprint").
1041
+ *
1042
+ * ## The checks-only file (T42, R-9, FR-70)
1043
+ *
1044
+ * `image` and `none` never produce a Build (plan §7.2 step 2: "`strategy`
1045
+ * `image`/`none` → no Build"), so a file generated for one of them must not carry
1046
+ * a `build` job — and §4.14 fixes what it carries instead, exactly: "the header,
1047
+ * `on: pull_request` only (no `push`, no `workflow_dispatch`), `permissions: {}`,
1048
+ * the same concurrency block and the `checks` job — nothing else". That is
1049
+ * {@link checksOnlyWorkflow}, reached through {@link checksOnlyFile}. With no
1050
+ * check left there is no workflow content at all, and this function answers the
1051
+ * empty string: §4.6 step 8's "with no checks, nothing is written", which
1052
+ * `src/repo/workflow-writer.ts` turns into a removal proposed by pull request
1053
+ * (FR-70, ACC-05-30).
1054
+ *
1055
+ * Two details of that file are deliberate and are why they are listed here:
1056
+ *
1057
+ * - **No `run-name`.** §4.14's "nothing else" and the normative draft
1058
+ * `docs/specs/features/app-works/APW-05-builds/golden-draft/checks.ever-works-build.yml`
1059
+ * (which carries `run-name` in every other draft and none here) agree: nothing
1060
+ * correlates a checks-only run to a Build, because §4.14's observation records
1061
+ * none for this strategy.
1062
+ * - **Its concurrency group is the draft's, not the full file's** — the one
1063
+ * place where §4.14's prose ("the same concurrency block") and §4.14's own
1064
+ * draft disagree. The draft wins, on a measurement: the full file's group names
1065
+ * `inputs.ew_mode` and `inputs.ew_build_id`, and the `inputs` context exists
1066
+ * only for `workflow_dispatch`/`workflow_call`, so a checks-only file that
1067
+ * carried it would be a file GitHub may refuse. `actionlint` v1.7.12 agrees —
1068
+ * on the shared block it exits 1 with
1069
+ * `checks-only.yml:11:15: property "ew_mode" is not defined in object type {}`
1070
+ * (and `:11:66` for `ew_build_id`); on the draft's group it exits 0. Both
1071
+ * blocks come from ONE helper, {@link concurrencyLines}, so they cannot drift;
1072
+ * `generator.spec.ts` pins each shape from both sides. Routed as a finding
1073
+ * against §4.14's sentence, which is the artefact that should be corrected.
1074
+ *
1075
+ * ## What this generator deliberately does not emit
1076
+ *
1077
+ * - **An attestation step.** Plan §4.5 says a private repository never gets "the
1078
+ * attestation permissions or steps", but §4.3's pin set carries no attestation
1079
+ * action and §2.4's step list no attestation step, so only the two permissions
1080
+ * are emitted. Routed, not invented.
1081
+ *
1082
+ * ## Three readings this file had to fix
1083
+ *
1084
+ * - **The branch tag.** §2.4 writes `format('branch-{0}', '<branch slug>')`; the
1085
+ * slug is known at generation time, so it is emitted as the literal
1086
+ * `'branch-<slug>'` inside the same expression. Same value, one less
1087
+ * indirection, and the golden contains the tag verbatim.
1088
+ * - **The restricted literal** (XC-01). §4.7b:948–952 says "a fixed non-secret
1089
+ * marker, one per value name so a build script can still distinguish 'absent'
1090
+ * from 'present'". The marker is used **verbatim**
1091
+ * (`APP_BUILD_RESTRICTED_VALUE_LITERAL`) rather than suffixed with the name:
1092
+ * the constant is the platform's single definition of the marker, and a
1093
+ * name-carrying variant could be mistaken for a value.
1094
+ * - **The build job's "Verify in the runner" step.** §2.4 emits it guarded by
1095
+ * `inputs.ew_verify_plan != ''`, and `ew_verify_plan` is only ever non-empty
1096
+ * for a `verify`-mode dispatch — which the `build` job excludes. It is emitted
1097
+ * as §2.4 writes it and reported as unreachable rather than quietly dropped.
1098
+ */
1099
+ /** The App Work's repository as the workflow needs it — the emit-time half of plan §4.5's canonical inputs. */
1100
+ interface WorkflowRepositoryInput {
1101
+ readonly owner: string;
1102
+ readonly repo: string;
1103
+ readonly visibility: 'public' | 'private';
1104
+ }
1105
+ /** The runner the workflow asks for — T11 selects it, this file only prints it (plan §4.5). */
1106
+ interface WorkflowRunnerInput {
1107
+ readonly label: string;
1108
+ readonly class: AppBuildRunnerClass;
1109
+ }
1110
+ /** The plan §4.4 settings that change the emitted bytes. */
1111
+ interface WorkflowSettingsInput {
1112
+ readonly reclaimDisk?: boolean;
1113
+ /** Honoured only for a public repository — the effective value is resolved here (plan §4.5, `APW05-G07`). */
1114
+ readonly attestations?: boolean;
1115
+ readonly allowBuildValuesOnPullRequests?: boolean;
1116
+ readonly verificationPromptedValuesRequireApproval?: boolean;
1117
+ }
1118
+ /** One App spec check, as `PrepareRepositoryInput.checks` carries it (R-9, plan §4.1). */
1119
+ interface WorkflowCheckInput {
1120
+ readonly name: string;
1121
+ readonly command: string;
1122
+ readonly required: boolean;
1123
+ readonly timeoutSeconds: number;
1124
+ }
1125
+ /** Everything the generator needs (plan §4.5's canonical inputs, plus the emit-time facts). */
1126
+ interface WorkflowGeneratorInput {
1127
+ readonly trackedBranch: string;
1128
+ readonly repository: WorkflowRepositoryInput;
1129
+ /** The hash of the App spec this file was generated from — the `io.ever-works.app-spec-hash` label (plan §2.4). */
1130
+ readonly appSpecHash: string;
1131
+ /** The App spec's `build` block; `null` (or absent) for the bootstrap file of plan §4.6 step 0. */
1132
+ readonly build?: AppBuildBlock | null;
1133
+ readonly values: readonly BuildValue[];
1134
+ readonly runner: WorkflowRunnerInput;
1135
+ readonly settings?: WorkflowSettingsInput;
1136
+ /** Plan §4.5: true exactly when the file carries the `verify` job that can run for this App Work. */
1137
+ readonly verifyEnabled: boolean;
1138
+ readonly checks?: readonly WorkflowCheckInput[];
1139
+ /** Plan §4.6 step 0: a dispatch-only file with no `build` job and no `checks` job. */
1140
+ readonly bootstrap?: boolean;
1141
+ /** T15's embedded verification script; defaults to {@link EMBEDDED_VERIFY_RUNNER_SCRIPT}. */
1142
+ readonly verifyRunnerScript?: string;
1143
+ }
1144
+ /**
1145
+ * A YAML double-quoted scalar, always.
1146
+ *
1147
+ * Plan §4.5: "a `yamlString()` helper that always double-quotes and escapes, so
1148
+ * the same inputs yield identical bytes on every platform". Everything the
1149
+ * generator interpolates goes through here — including expressions, whose `{`,
1150
+ * `}`, `:` and quotes would otherwise depend on the reader's YAML dialect.
1151
+ */
1152
+ declare function yamlString(value: string): string;
1153
+ /**
1154
+ * Plan §4.5's branch slug: lower case, `[^a-z0-9._-]` → `-`, runs collapsed,
1155
+ * trimmed to {@link APP_BUILD_BRANCH_SLUG_MAX_CHARS}.
1156
+ */
1157
+ declare function branchSlug(branch: string): string;
1158
+ /** The image the build publishes (plan §2.4 `EW_IMAGE`, `APP_BUILD_IMAGE_NAME`). */
1159
+ declare function buildImageRepository(repository: WorkflowRepositoryInput): string;
1160
+ /** The `EW_` secret names a build job writes, space separated — `secret` and not build-service derived (§2.4, §4.11). */
1161
+ declare function secretNamesFor(values: readonly BuildValue[]): string[];
1162
+ /** The `fromEnv` names the build arguments read, in the order the build block declares them. */
1163
+ declare function fromEnvNames(build: AppBuildBlock): string[];
1164
+ /** Normalise the App spec's build block: defaults applied, keys sorted (plan §4.5). */
1165
+ declare function normaliseBuildBlock(build: AppBuildBlock): CanonicalBuildBlock;
1166
+ /** The canonical inputs of one generation — what the header line's fingerprint covers. */
1167
+ declare function canonicalInputsFor(input: WorkflowGeneratorInput, verifyRunnerScript: string): CanonicalWorkflowInputs;
1168
+ /** The header every generated file carries — plan §2.4:157–159, FR-6. */
1169
+ declare function workflowHeader(inputsHash: string): string[];
1170
+ /**
1171
+ * Generate the workflow file (plan §2.4).
1172
+ *
1173
+ * Byte-stable by construction: every branch below is a pure function of
1174
+ * {@link WorkflowGeneratorInput}, and the two unordered collections (build args,
1175
+ * services) are emitted in their declared order while the fingerprint sorts them
1176
+ * again for the hash.
1177
+ *
1178
+ * Three shapes come out of it, and the first is T42's:
1179
+ *
1180
+ * 1. **the checks-only file** ({@link checksOnlyFile}) — §4.14, or the empty
1181
+ * string when no check is left (§4.6 step 8);
1182
+ * 2. **the bootstrap file** (`bootstrap: true`) — dispatch-only, `verify` alone
1183
+ * (§4.6 step 0);
1184
+ * 3. **the full file** — `build`, `verify` and, when the App spec declares any,
1185
+ * `checks`.
1186
+ */
1187
+ declare function generateWorkflow(input: WorkflowGeneratorInput): string;
1188
+
1189
+ /**
1190
+ * APW-05 T8 — the embedded verification script, as the generated workflow
1191
+ * carries it (`run: |` under the "Verify in the runner" step of §2.4).
1192
+ *
1193
+ * ## This is a deliberate interlock shell, not the verification
1194
+ *
1195
+ * Plan §4.3 gives T15 `src/workflow/verify-runner.sh.ts` — "the embedded
1196
+ * verification script as a template literal": the plan schema check, the 12 GiB
1197
+ * refusal, the throwaway dependency containers, the recipe materialisation into a
1198
+ * `0600` env file, the readiness waits, the smoke requests, the shred. **None of
1199
+ * that exists yet.** T8 needs *a* script because §2.4's step list carries the
1200
+ * step and T12/T13's observation and classification paths key off its name
1201
+ * ("Verify in the runner" → `verificationFailed`, plan §4.9 row 10).
1202
+ *
1203
+ * So the constant below does the only honest thing an unfinished script can do:
1204
+ * it **fails loudly**, before touching anything, naming the task that owes the
1205
+ * real one. A verification Build dispatched today ends `failed` with
1206
+ * `EW_VERIFY_NOT_IMPLEMENTED` on its stderr — it does not report success, does not
1207
+ * silently skip the checks the owner asked for, and cannot be mistaken for a
1208
+ * passing verification.
1209
+ *
1210
+ * **T15 replaces this constant's body** (`EMBEDDED_VERIFY_RUNNER_SCRIPT`) and
1211
+ * leaves the name, the export and its use in `generator.ts` alone. Because the
1212
+ * generator hashes the script into the canonical inputs
1213
+ * (`verifyRunnerScriptSha256`), that replacement moves `inputsHash` on its own and
1214
+ * every App Work gets a commit or pull request with the new file on its next
1215
+ * preparation (plan §4.5) — no second place to remember.
1216
+ */
1217
+ declare const EMBEDDED_VERIFY_RUNNER_SCRIPT: string;
1218
+
1219
+ /**
1220
+ * APW-05 T9 — is the tracked branch protected? (plan §4.6 step 1, `APW05-G16`)
1221
+ *
1222
+ * Two reads, because one is not enough: the classic endpoint
1223
+ * `GET /repos/{o}/{r}/branches/{branch}/protection` answers **404** for a branch
1224
+ * that is protected only by a **ruleset**, so a plugin that trusted it alone would
1225
+ * commit straight into a protected branch. The rules read
1226
+ * (`GET /repos/{o}/{r}/rules/branches/{branch}`, paginated, read access only)
1227
+ * closes that hole.
1228
+ *
1229
+ * The verdict carries **which read decided it**, because "protected" is not a
1230
+ * boolean anyone can act on: a 403 is treated as protected *without* knowing why
1231
+ * (the token cannot see the rules), and the delivery rules of plan §4.6 step 2
1232
+ * then rely on `refRejectedByRule` as the fallback. That distinction is what the
1233
+ * T9 spec asserts, and it is the difference between a branch we know is protected
1234
+ * and one we merely cannot prove is open.
1235
+ *
1236
+ * Nothing here writes, and nothing here throws on a provider refusal: a 403 or a
1237
+ * 404 from either endpoint is an *answer* (the plan's own words: "A 403 or 404
1238
+ * from the rules endpoint means no rules are known locally; the refusal fallback
1239
+ * of step 2 covers that case").
1240
+ */
1241
+ /** One endpoint's answer — a status and, when there is one, a body. A refusal is data, not an exception. */
1242
+ interface BranchProtectionResponse<T> {
1243
+ readonly ok: boolean;
1244
+ readonly status: number;
1245
+ readonly data?: T;
1246
+ }
1247
+ /** The fields of the classic protection payload this decision reads (plan §4.6 step 1). */
1248
+ interface BranchProtectionPayload {
1249
+ readonly required_pull_request_reviews?: unknown;
1250
+ readonly required_status_checks?: unknown;
1251
+ }
1252
+ /** One rule from `GET /rules/branches/{branch}` — the fields this decision reads. */
1253
+ interface BranchRulePayload {
1254
+ readonly type?: string;
1255
+ /** Present on some payloads; when it is, only `active` counts (plan §4.6 step 1). */
1256
+ readonly enforcement?: string;
1257
+ }
1258
+ /** The two reads the branch-rules decision needs, and nothing else. */
1259
+ interface BranchProtectionPort {
1260
+ readBranchProtection(input: {
1261
+ readonly owner: string;
1262
+ readonly repo: string;
1263
+ readonly branch: string;
1264
+ }): Promise<BranchProtectionResponse<BranchProtectionPayload>>;
1265
+ readBranchRules(input: {
1266
+ readonly owner: string;
1267
+ readonly repo: string;
1268
+ readonly branch: string;
1269
+ readonly page: number;
1270
+ }): Promise<BranchProtectionResponse<readonly BranchRulePayload[]>>;
1271
+ }
1272
+ /** Which read decided the verdict — the reason, not just the boolean. */
1273
+ type BranchProtectionDecision = 'requiredPullRequestReviews' | 'requiredStatusChecks' | 'forbidden' | 'rulesetPullRequest' | 'rulesetStatusChecks' | 'none';
1274
+ /** The decision and the read that produced it. */
1275
+ interface BranchProtectionVerdict {
1276
+ readonly protected: boolean;
1277
+ readonly decision: BranchProtectionDecision;
1278
+ }
1279
+ /** The branch coordinates both reads address. */
1280
+ interface BranchCoordinates {
1281
+ readonly owner: string;
1282
+ readonly repo: string;
1283
+ readonly branch: string;
1284
+ }
1285
+ /** Page size for the rules read — GitHub's maximum, so the pagination loop is as short as it can be. */
1286
+ declare const BRANCH_RULES_PAGE_SIZE = 100;
1287
+ /**
1288
+ * How many rules pages are read before the loop stops.
1289
+ *
1290
+ * A bound, not a guess: an active `pull_request` or `required_status_checks` rule
1291
+ * is almost always on the first page, and a repository with more than a thousand
1292
+ * branch rules is not a case worth hanging a preparation job on. Reaching the cap
1293
+ * without a match answers `unprotected`, which the `refRejectedByRule` fallback
1294
+ * of §4.6 step 2 covers.
1295
+ */
1296
+ declare const BRANCH_RULES_MAX_PAGES = 10;
1297
+ /** The two rule types plan §4.6 step 1 names. */
1298
+ declare const BRANCH_PROTECTION_RULE_TYPES: readonly ["pull_request", "required_status_checks"];
1299
+ /**
1300
+ * Decide whether the tracked branch is protected (plan §4.6 step 1).
1301
+ *
1302
+ * Order, and why:
1303
+ *
1304
+ * 1. the classic endpoint — `403` is **protected** (we cannot see the rules, so
1305
+ * we do not get to assume the branch is open); a `200` protects when either
1306
+ * `required_pull_request_reviews` or `required_status_checks` is present;
1307
+ * 2. the rules endpoint — consulted on a `404` **and** on a `200` that carried
1308
+ * neither field, because a ruleset can protect a branch the classic endpoint
1309
+ * reports as unprotected.
1310
+ */
1311
+ declare function isBranchProtected(port: BranchProtectionPort, coordinates: BranchCoordinates): Promise<BranchProtectionVerdict>;
1312
+
1313
+ /**
1314
+ * APW-05 T9 — delivering the generated workflow (plan §4.6, `APW05-G16`, R-4).
1315
+ *
1316
+ * ## The one rule that shapes everything here
1317
+ *
1318
+ * **The plugin never clones.** Every write goes through the `RepositoryWriter` the
1319
+ * facade binds (APW-03's `commitFiles?` via `GitFacadeService`), whose four members
1320
+ * are all this file calls: `getFileContent`, `commitFiles`, `createBranch`,
1321
+ * `createPullRequest`. Writing one file must not clone a 1 GB repository
1322
+ * (plan §1.2), and the T9 spec asserts that no clone-shaped call exists — the fake
1323
+ * writer has no clone member to call.
1324
+ *
1325
+ * ## The paths, and which one a branch gets
1326
+ *
1327
+ * | Branch state | Path |
1328
+ * | ------------------------------------------------------------- | ---------------------------------------- |
1329
+ * | File on the tracked branch already equals the generated bytes | `unchanged`, **zero write calls** |
1330
+ * | File exists, its sha256 is neither the stored one nor the new one | **hand edit** → pull request, never an overwrite on the tracked branch (FR-9) |
1331
+ * | `createdByAppWork === false` (a Link relation) | pull request, always (R-4) |
1332
+ * | Protected (reviews, status checks, ruleset, or a 403) | pull request |
1333
+ * | Otherwise | direct commit onto the tracked branch |
1334
+ * | **No workflow content** (T42: no build and no check remains) | pull request — the removal of §4.6 step 8, never a direct commit (FR-70) |
1335
+ *
1336
+ * The pull request path reuses **one** pull request: `ever-works/build-workflow` is
1337
+ * created from the tracked head when absent, the commit lands on it, and the pull
1338
+ * request is then ALWAYS asked for. Reuse is proven by GitHub, never assumed from
1339
+ * a stored number: when an open pull request already exists for that head, the
1340
+ * create is refused with `pullRequestExists` (the contract's code, or GitHub's own
1341
+ * 422 "A pull request already exists" when nothing translated it), and that
1342
+ * refusal is what `pullRequestUpdated` means (ACC-05-02). The stored
1343
+ * `pullRequestNumber`/`pullRequestUrl` are only echoed back so the reused pull
1344
+ * request keeps its link; a stored pull request the owner CLOSED is therefore
1345
+ * replaced by a new one rather than "updated" forever. A second and a third
1346
+ * preparation leave exactly one open pull request, and a preparation whose bytes
1347
+ * are already on that branch commits nothing at all.
1348
+ *
1349
+ * ## The refusals
1350
+ *
1351
+ * - `nonFastForward` — a race: retry the direct write, at most
1352
+ * {@link WORKFLOW_WRITE_MAX_ATTEMPTS} attempts in total (plan §4.6 step 2's
1353
+ * "up to 3 times"), then `blocked workflowWriteFailed`.
1354
+ * - `refRejectedByRule` — a protection or ruleset the pre-check could not see:
1355
+ * switch to the pull request path **once** and never retry the direct write.
1356
+ * If the pull request path is refused the same way, stop with
1357
+ * `blocked workflowWriteFailed`, detail `{ cause: 'branchRules' }` — never a
1358
+ * loop. Its copy is "The build workflow could not be written." plus "A rule on
1359
+ * this repository refuses the change." (plan §4.6 steps 2–3).
1360
+ * - a read-back that does not match: retried **once**; still mismatched →
1361
+ * `blocked workflowWriteFailed`, and `storedWorkflowSha256` stays untouched —
1362
+ * only a matching read-back may store a hash (FR-8).
1363
+ *
1364
+ * ## Where the branch head comes from
1365
+ *
1366
+ * `commitFiles` needs the commit the caller believes the branch is at, and the
1367
+ * `RepositoryWriter` contract has no `getBranch` member. Two ways in, both within
1368
+ * the four:
1369
+ *
1370
+ * 1. `input.resolveTrackedHead` — the facade, which already holds
1371
+ * `GitFacadeService`, passes the real head (T16's binding);
1372
+ * 2. otherwise {@link readBranchHead} calls `createBranch(branch, branch)`, which
1373
+ * the contract documents as returning an existing branch rather than
1374
+ * recreating it — a read that happens to be spelled as a create.
1375
+ *
1376
+ * The head is re-read on **every** attempt, which is what makes the
1377
+ * `nonFastForward` retry real rather than a repeat of the same stale commit.
1378
+ */
1379
+ /** The commit message of a file that did not exist (plan §4.6 step 2). */
1380
+ declare const WORKFLOW_COMMIT_MESSAGE_ADD = "Add Ever Works build workflow";
1381
+ /** The commit message of a file the platform is changing (plan §4.6 step 2). */
1382
+ declare const WORKFLOW_COMMIT_MESSAGE_UPDATE = "Update Ever Works build workflow";
1383
+ /** The pull request title (plan §4.6 step 3). */
1384
+ declare const WORKFLOW_PULL_REQUEST_TITLE = "Add Ever Works build workflow";
1385
+ /** The pull request body — what a reviewer is looking at, and what not to do with it. */
1386
+ declare const WORKFLOW_PULL_REQUEST_BODY = "Ever Works generated this workflow from the App spec. It builds the image for this App Work on GitHub-hosted runners and publishes it to GHCR. Hand edits to this file are never overwritten: Ever Works proposes its changes as pull requests.";
1387
+ /**
1388
+ * How many direct-write attempts are made in total (plan §4.6 step 2: "A
1389
+ * `nonFastForward` error retries from step 1 up to 3 times").
1390
+ *
1391
+ * Read as at most three attempts, which is the stricter of the two readings of
1392
+ * "up to 3 times" and therefore cannot exceed the bound the plan states.
1393
+ */
1394
+ declare const WORKFLOW_WRITE_MAX_ATTEMPTS = 3;
1395
+ /** How many read-backs one successful write gets: the first, plus the plan's single retry (FR-8). */
1396
+ declare const WORKFLOW_READ_BACK_ATTEMPTS = 2;
1397
+ /** The tracked branch and the App Work's relation to it. */
1398
+ interface WorkflowRepositoryCoordinates {
1399
+ readonly owner: string;
1400
+ readonly repo: string;
1401
+ readonly trackedBranch: string;
1402
+ /** `false` for a Link relation — that repository is not ours to write into (R-4). */
1403
+ readonly createdByAppWork: boolean;
1404
+ }
1405
+ /** What the caller asks to deliver, and what it remembers from the last delivery. */
1406
+ interface WorkflowWriteInput {
1407
+ readonly repository: WorkflowRepositoryCoordinates;
1408
+ /** The generated bytes. */
1409
+ readonly content: string;
1410
+ /** The workflow sha256 the platform last wrote, or `null` — the other half of hand-edit detection (FR-9). */
1411
+ readonly lastWrittenWorkflowSha256: string | null;
1412
+ /**
1413
+ * The pull request the platform recorded for this App Work (§3.1b), when there is one.
1414
+ * Echoed back when GitHub reports an open pull request for the head; never used to
1415
+ * decide that one is open.
1416
+ */
1417
+ readonly pullRequestNumber?: number | null;
1418
+ /** That pull request's URL, echoed back on the reuse path (the writer never re-reads it). */
1419
+ readonly pullRequestUrl?: string | null;
1420
+ /** Overrides {@link APP_BUILD_WORKFLOW_PATH}; exists so a spec can name its own path. */
1421
+ readonly workflowPath?: string;
1422
+ /** The facade's branch-head read (T16). Without it, {@link readBranchHead} is used. */
1423
+ readonly resolveTrackedHead?: () => Promise<string>;
1424
+ }
1425
+ /** What the delivery decided — the transport state §3.1b maps onto `workflowState`. */
1426
+ interface WorkflowWriteResult {
1427
+ /**
1428
+ * The delivery outcome.
1429
+ *
1430
+ * `'blocked'` is this writer's own terminal state and is **not** a member of
1431
+ * `PrepareRepositoryResult.workflow.state` (which has five): the caller maps it
1432
+ * onto `PrepareRepositoryResult.blocked` with reason `workflowWriteFailed`, and
1433
+ * must not pass it through as a stored workflow state.
1434
+ */
1435
+ readonly state: 'unchanged' | 'committed' | 'pullRequestOpened' | 'pullRequestUpdated' | 'editedByHand' | 'blocked';
1436
+ readonly commitSha?: string;
1437
+ readonly pullRequestNumber?: number;
1438
+ readonly pullRequestUrl?: string;
1439
+ /**
1440
+ * The sha256 the platform may store as `lastWrittenWorkflowSha256`, or `null`
1441
+ * when nothing on the tracked branch has been proven to equal the generated
1442
+ * bytes. Only a matching read-back sets it (FR-8, plan §4.6 step 4).
1443
+ */
1444
+ readonly storedWorkflowSha256: string | null;
1445
+ readonly blocked?: {
1446
+ readonly reason: 'workflowWriteFailed';
1447
+ readonly detail: {
1448
+ readonly cause: string;
1449
+ };
1450
+ };
1451
+ }
1452
+ /** The refusal code of a thrown write error, if it is one of the codes the contract names. */
1453
+ declare function repositoryWriteErrorCode(error: unknown): RepositoryWriteErrorCode | null;
1454
+ /**
1455
+ * Deliver the generated workflow (plan §4.6).
1456
+ *
1457
+ * `protection` is the branch-rules port; leave it out when the caller has no
1458
+ * protection read (a fork the platform owns, a spec), in which case only
1459
+ * `createdByAppWork` and hand-edit detection decide the path.
1460
+ *
1461
+ * **Empty content is T42's removal request** (plan §4.6 step 8, FR-70): the
1462
+ * generator answers `''` when neither a build nor a check remains, and
1463
+ *
1464
+ * - with no file on the tracked branch there is nothing to remove — no write, no
1465
+ * pull request, `unchanged` (and no stored hash: nothing was proven);
1466
+ * - with one, the removal is **proposed by pull request** and never committed to
1467
+ * the tracked branch: §4.6 step 8's "never deleted directly". The branch
1468
+ * protections of steps 1–2 are not consulted, because there is no direct path
1469
+ * to protect; `createdByAppWork` and the hand-edit verdict still decide the
1470
+ * state the caller stores (§3.1b).
1471
+ */
1472
+ declare function writeWorkflow(input: WorkflowWriteInput, writer: RepositoryWriter, protection?: BranchProtectionPort): Promise<WorkflowWriteResult>;
1473
+
1474
+ export { ACTION_PINS, ACTION_PIN_SHA_PATTERN, type ActionPin, type ActionPinKey, BRANCH_PROTECTION_RULE_TYPES, BRANCH_RULES_MAX_PAGES, BRANCH_RULES_PAGE_SIZE, type BranchCoordinates, type BranchProtectionDecision, type BranchProtectionPayload, type BranchProtectionPort, type BranchProtectionResponse, type BranchProtectionVerdict, type BranchRulePayload, type BuildValueSecretSync, type BuildValueSyncInput, type BuildValueSyncResult, CANONICAL_INPUT_FIELDS, type CanonicalBuildArg, type CanonicalBuildBlock, type CanonicalBuildService, type CanonicalBuildValue, type CanonicalCheck, type CanonicalRunner, type CanonicalSettings, type CanonicalWorkflowInputs, EMBEDDED_VERIFY_RUNNER_SCRIPT, GENERATOR_RESERVED_LITERALS, GITHUB_ACTIONS_BUILD_PLATFORM_MANAGED_KEYS, GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS, GITHUB_ACTIONS_BUILD_SETTING_KEYS, GITHUB_SECRET_NAME_PATTERN, GitHubActionsBuildPlugin, type GitHubActionsBuildSettingKey, type GitHubActionsBuildSettings, type GitHubActionsBuildSettingsSchema, LARGER_RUNNER_LABEL_PATTERN, LARGER_RUNNER_MEMORY_MAX_GIB, LARGER_RUNNER_MEMORY_MIN_GIB, LARGER_RUNNER_VCPU_MAX, LARGER_RUNNER_VCPU_MIN, PREPARATION_STATE_KEYS, type PlatformManagedJsonSchema, type RepositorySecretPort, type SecretSyncDependencies, type SecretSyncLogger, type VerifyPromptedSecretResult, WORKFLOW_COMMIT_MESSAGE_ADD, WORKFLOW_COMMIT_MESSAGE_UPDATE, WORKFLOW_PULL_REQUEST_BODY, WORKFLOW_PULL_REQUEST_TITLE, WORKFLOW_READ_BACK_ATTEMPTS, WORKFLOW_WRITE_MAX_ATTEMPTS, type WorkflowCheckInput, type WorkflowGeneratorInput, type WorkflowRepositoryCoordinates, type WorkflowRepositoryInput, type WorkflowRunnerInput, type WorkflowSettingsInput, type WorkflowWriteInput, type WorkflowWriteResult, actionPin, actionPinReference, branchSlug, buildImageRepository, buildSecretName, canonicalActionPins, canonicalCheck, canonicalInputsFor, canonicalWorkflowJson, canonicaliseWorkflowInputs, checkTimeoutMinutes, computeWorkflowInputsHash, createBuildValueSecretSync, GitHubActionsBuildPlugin as default, fromEnvNames, generateWorkflow, gitHubActionsBuildSettingsSchema, isBranchProtected, isSecretLimitError, isValidGitHubSecretName, loadSodium, normaliseBuildBlock, notImplemented, repositoryWriteErrorCode, sealSecretValue, secretNamesFor, workflowHeader, writeWorkflow, yamlString };