@kici-dev/sdk 0.4.0 → 0.6.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/dist/agent-cloud-init.d.ts +66 -0
- package/dist/agent-cloud-init.js +128 -0
- package/dist/api-types.d.ts +26 -0
- package/dist/api-types.js +20 -1
- package/dist/context.d.ts +21 -0
- package/dist/filter-context.d.ts +32 -0
- package/dist/filter-context.js +36 -0
- package/dist/filter.d.ts +81 -0
- package/dist/filter.js +2 -0
- package/dist/filter.test-d.d.ts +2 -0
- package/dist/filter.test-d.js +30 -0
- package/dist/git-types.d.ts +162 -0
- package/dist/git-types.js +32 -0
- package/dist/idempotent.js +3 -2
- package/dist/index.d.ts +11 -1
- package/dist/index.js +11 -5
- package/dist/invoke.d.ts +29 -0
- package/dist/invoke.js +27 -0
- package/dist/job-outputs.test-d.js +32 -18
- package/dist/job.js +93 -1
- package/dist/needs-context.d.ts +31 -2
- package/dist/needs-context.js +3 -1
- package/dist/rules/changed-files.d.ts +37 -0
- package/dist/rules/changed-files.js +54 -0
- package/dist/rules/context.d.ts +12 -11
- package/dist/rules/context.js +7 -25
- package/dist/rules/evaluator.js +2 -1
- package/dist/rules/index.js +2 -1
- package/dist/rules/types.d.ts +9 -0
- package/dist/testing/step-context.js +4 -2
- package/dist/triggers/pr.js +2 -0
- package/dist/triggers/push.js +2 -0
- package/dist/triggers/tag.js +2 -0
- package/dist/triggers/types.d.ts +26 -0
- package/dist/types.d.ts +119 -3
- package/dist/workflow.js +2 -0
- package/package.json +4 -3
- package/sbom.spdx.json +64 -34
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Pass a claim code (ClaimCodeCredentials) so the token never
|
|
3
|
+
* transits cloud-init. Removed at v1.0.0.
|
|
4
|
+
*/
|
|
5
|
+
export interface CloudInitCredentials {
|
|
6
|
+
agentToken: string;
|
|
7
|
+
agentId: string;
|
|
8
|
+
orchestratorUrl: string;
|
|
9
|
+
labels: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Preferred: the agent self-claims from a single-use code; the token is minted in-instance. */
|
|
12
|
+
export interface ClaimCodeCredentials {
|
|
13
|
+
claimCode: string;
|
|
14
|
+
agentId: string;
|
|
15
|
+
orchestratorUrl: string;
|
|
16
|
+
labels: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Either credential shape accepted by `buildAgentCloudInit`. */
|
|
19
|
+
export type AgentCloudInitCredentials = CloudInitCredentials | ClaimCodeCredentials;
|
|
20
|
+
/** How the agent binary is delivered onto the instance. */
|
|
21
|
+
export type AgentDeliveryMode = 'container' | 'payload';
|
|
22
|
+
/** How the rendered `user_data` string is encoded before it is returned. */
|
|
23
|
+
export type UserDataEncoding = 'raw' | 'base64';
|
|
24
|
+
/** A cloud-init `write_files` entry the caller adds. */
|
|
25
|
+
export interface CloudInitWriteFile {
|
|
26
|
+
path: string;
|
|
27
|
+
content: string;
|
|
28
|
+
permissions?: string;
|
|
29
|
+
owner?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface AgentCloudInitOptions {
|
|
32
|
+
/** Hard lifetime cap (minutes) after which the instance powers itself off (L2). */
|
|
33
|
+
maxLifetimeMinutes: number;
|
|
34
|
+
/** 'container' (docker run the published agent image) | 'payload' (fetch from orchestrator). */
|
|
35
|
+
deliveryMode?: AgentDeliveryMode;
|
|
36
|
+
/** Container image ref (container mode). */
|
|
37
|
+
agentImage?: string;
|
|
38
|
+
/** Escape hatch: fully override the agent-start command (ignores deliveryMode/agentImage). */
|
|
39
|
+
startCommand?: string;
|
|
40
|
+
/** apt/yum packages → cloud-init `packages:` (unioned with the base). */
|
|
41
|
+
packages?: string[];
|
|
42
|
+
/** Extra `write_files` entries (the reserved env-file path is rejected). */
|
|
43
|
+
writeFiles?: CloudInitWriteFile[];
|
|
44
|
+
/** runcmd lines injected BEFORE the agent starts. */
|
|
45
|
+
runcmdBefore?: string[];
|
|
46
|
+
/** runcmd lines injected AFTER the agent starts. */
|
|
47
|
+
runcmdAfter?: string[];
|
|
48
|
+
/** Extra env appended to the agent env file (keys validated, newline values rejected). */
|
|
49
|
+
agentEnv?: Record<string, string>;
|
|
50
|
+
/** Raw cloud-config YAML to merge everything into (users, ssh, apt, mounts, bootcmd, …). */
|
|
51
|
+
baseCloudConfig?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Encoding of the returned `user_data` string. `'raw'` (the default) returns
|
|
54
|
+
* the plain `#cloud-config` text — what Hetzner `user_data` expects. `'base64'`
|
|
55
|
+
* returns the same text base64-encoded, the form AWS EC2 `UserData` and Azure
|
|
56
|
+
* `customData` expect.
|
|
57
|
+
*/
|
|
58
|
+
userDataEncoding?: UserDataEncoding;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the cloud-config `user_data` string. In the token form the agent token
|
|
62
|
+
* appears only in the reserved env-file write entry (`0600`, root-only); the
|
|
63
|
+
* claim-code form keeps the token off the provisioning channel entirely.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildAgentCloudInit(creds: AgentCloudInitCredentials, options: AgentCloudInitOptions): string;
|
|
66
|
+
//# sourceMappingURL=agent-cloud-init.d.ts.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import "./rolldown-runtime-ClRpJifh.js";
|
|
2
|
+
import { parse, stringify } from "yaml";
|
|
3
|
+
//#region src/agent-cloud-init.ts
|
|
4
|
+
/**
|
|
5
|
+
* Cloud-init `user_data` builder for a scaler-provisioned KiCI agent.
|
|
6
|
+
*
|
|
7
|
+
* Renders a `#cloud-config` that installs + starts the agent with the claimed
|
|
8
|
+
* ephemeral credentials, plus teardown layer L2 — an in-instance max-lifetime
|
|
9
|
+
* self-poweroff. The agent token is written ONLY into a root-only `0600` env
|
|
10
|
+
* file; it never appears in a comment, a process argument, or any other file.
|
|
11
|
+
*/
|
|
12
|
+
const AGENT_ENV_FILE = "/etc/kici-agent.env";
|
|
13
|
+
const DEFAULT_AGENT_IMAGE = "quay.io/kici-dev/kici-agent:latest";
|
|
14
|
+
/**
|
|
15
|
+
* The credential-specific env lines. The claim-code form emits a single-use
|
|
16
|
+
* code the agent exchanges for its own token in-instance; the token form emits
|
|
17
|
+
* the token directly (the ONLY place the token appears).
|
|
18
|
+
*/
|
|
19
|
+
function credentialEnvLines(creds) {
|
|
20
|
+
if ("claimCode" in creds) return [
|
|
21
|
+
`KICI_ORCHESTRATOR_URL=${creds.orchestratorUrl}`,
|
|
22
|
+
`KICI_SCALER_CLAIM_CODE=${creds.claimCode}`,
|
|
23
|
+
`KICI_AGENT_ID=${creds.agentId}`,
|
|
24
|
+
`KICI_LABELS=${creds.labels.join(",")}`
|
|
25
|
+
];
|
|
26
|
+
return [
|
|
27
|
+
`KICI_ORCHESTRATOR_URL=${creds.orchestratorUrl}`,
|
|
28
|
+
`KICI_AGENT_TOKEN=${creds.agentToken}`,
|
|
29
|
+
`KICI_AGENT_ID=${creds.agentId}`,
|
|
30
|
+
`KICI_LABELS=${creds.labels.join(",")}`
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
/** Render the agent env-file content (the ONLY place the token appears, in the token form). */
|
|
34
|
+
function renderEnvFileContent(creds, agentEnv) {
|
|
35
|
+
return `${[
|
|
36
|
+
...credentialEnvLines(creds),
|
|
37
|
+
"KICI_SCALER_MANAGED=1",
|
|
38
|
+
...Object.entries(agentEnv ?? {}).map(([k, v]) => `${k}=${v}`)
|
|
39
|
+
].join("\n")}\n`;
|
|
40
|
+
}
|
|
41
|
+
/** Dedupe a list preserving first-seen order. */
|
|
42
|
+
function dedupe(items) {
|
|
43
|
+
return [...new Set(items)];
|
|
44
|
+
}
|
|
45
|
+
/** POSIX env-name shape: a letter or underscore, then letters/digits/underscores. */
|
|
46
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
47
|
+
/** Reject agentEnv keys that are not POSIX env names and values carrying a newline. */
|
|
48
|
+
function validateAgentEnv(agentEnv) {
|
|
49
|
+
for (const [key, value] of Object.entries(agentEnv ?? {})) {
|
|
50
|
+
if (!ENV_NAME_RE.test(key)) throw new Error(`buildAgentCloudInit: invalid agentEnv key "${key}" (must be a POSIX env name)`);
|
|
51
|
+
if (value.includes("\n")) throw new Error(`buildAgentCloudInit: agentEnv value for "${key}" must not contain a newline`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Reject any caller/base write_files entry targeting the reserved env-file path. */
|
|
55
|
+
function assertNoReservedWrite(writeFiles, where) {
|
|
56
|
+
for (const wf of writeFiles ?? []) if (wf.path === AGENT_ENV_FILE) throw new Error(`buildAgentCloudInit: ${where} may not write the reserved path ${AGENT_ENV_FILE}`);
|
|
57
|
+
}
|
|
58
|
+
/** Parse + shape-check a caller-supplied base cloud-config. */
|
|
59
|
+
function parseBase(baseCloudConfig) {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = parse(baseCloudConfig);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`buildAgentCloudInit: baseCloudConfig is not valid YAML: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
if (parsed == null) return {};
|
|
67
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("buildAgentCloudInit: baseCloudConfig must be a cloud-config mapping");
|
|
68
|
+
const base = parsed;
|
|
69
|
+
for (const key of [
|
|
70
|
+
"packages",
|
|
71
|
+
"runcmd",
|
|
72
|
+
"write_files"
|
|
73
|
+
]) if (base[key] !== void 0 && !Array.isArray(base[key])) throw new Error(`buildAgentCloudInit: baseCloudConfig.${key} must be a list`);
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
/** Render the command that starts the agent for the chosen delivery mode. */
|
|
77
|
+
function renderStartCommand(opts) {
|
|
78
|
+
if (opts.startCommand) return opts.startCommand;
|
|
79
|
+
if (opts.deliveryMode === "payload") return "/usr/local/bin/kici-agent-bootstrap";
|
|
80
|
+
const image = opts.agentImage ?? DEFAULT_AGENT_IMAGE;
|
|
81
|
+
return `docker run -d --restart=no --name kici-agent --network host --env-file ${AGENT_ENV_FILE} ${image}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Build the cloud-config `user_data` string. In the token form the agent token
|
|
85
|
+
* appears only in the reserved env-file write entry (`0600`, root-only); the
|
|
86
|
+
* claim-code form keeps the token off the provisioning channel entirely.
|
|
87
|
+
*/
|
|
88
|
+
function buildAgentCloudInit(creds, options) {
|
|
89
|
+
validateAgentEnv(options.agentEnv);
|
|
90
|
+
assertNoReservedWrite(options.writeFiles, "writeFiles");
|
|
91
|
+
const cap = Math.max(1, Math.floor(options.maxLifetimeMinutes));
|
|
92
|
+
const base = options.baseCloudConfig ? parseBase(options.baseCloudConfig) : {};
|
|
93
|
+
assertNoReservedWrite(base.write_files, "baseCloudConfig");
|
|
94
|
+
const baseWriteFiles = base.write_files ?? [];
|
|
95
|
+
const baseRuncmd = base.runcmd ?? [];
|
|
96
|
+
const basePackages = base.packages ?? [];
|
|
97
|
+
const envFile = {
|
|
98
|
+
path: AGENT_ENV_FILE,
|
|
99
|
+
permissions: "0600",
|
|
100
|
+
owner: "root:root",
|
|
101
|
+
content: renderEnvFileContent(creds, options.agentEnv)
|
|
102
|
+
};
|
|
103
|
+
const pkgs = dedupe([...basePackages, ...options.packages ?? []]);
|
|
104
|
+
const model = {
|
|
105
|
+
...base,
|
|
106
|
+
write_files: [
|
|
107
|
+
...baseWriteFiles,
|
|
108
|
+
envFile,
|
|
109
|
+
...options.writeFiles ?? []
|
|
110
|
+
],
|
|
111
|
+
runcmd: [
|
|
112
|
+
...baseRuncmd,
|
|
113
|
+
...options.runcmdBefore ?? [],
|
|
114
|
+
`systemd-run --on-active=${cap}m --timer-property=AccuracySec=1s /sbin/poweroff`,
|
|
115
|
+
renderStartCommand(options),
|
|
116
|
+
...options.runcmdAfter ?? []
|
|
117
|
+
]
|
|
118
|
+
};
|
|
119
|
+
if (pkgs.length > 0) model.packages = pkgs;
|
|
120
|
+
else delete model.packages;
|
|
121
|
+
const cloudConfig = `#cloud-config\n${stringify(model, { lineWidth: 0 })}`;
|
|
122
|
+
if (options.userDataEncoding === "base64") return Buffer.from(cloudConfig, "utf8").toString("base64");
|
|
123
|
+
return cloudConfig;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { buildAgentCloudInit };
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=agent-cloud-init.js.map
|
package/dist/api-types.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 2. Register the handler in the orchestrator's AgentApiRegistry
|
|
10
10
|
*/
|
|
11
11
|
import { type OidcTokenResult } from '@kici-dev/engine/protocol/messages/oidc-token-relay';
|
|
12
|
+
import type { GitApi } from './git-types.js';
|
|
12
13
|
import type { HostInventoryEntry, InventorySelector } from '@kici-dev/engine';
|
|
13
14
|
export type { OidcTokenResult };
|
|
14
15
|
export type { HostInventoryEntry, InventorySelector };
|
|
@@ -136,6 +137,27 @@ export interface BootstrapApi {
|
|
|
136
137
|
restaged: boolean;
|
|
137
138
|
}>;
|
|
138
139
|
}
|
|
140
|
+
/** Credentials a provisioning workflow uses to boot a scaler-provisioned agent. */
|
|
141
|
+
export interface ClaimedAgentCredentials {
|
|
142
|
+
/** Single-use ephemeral agent token the provisioned instance registers with. */
|
|
143
|
+
agentToken: string;
|
|
144
|
+
/** Agent id the instance must register with (chosen by the scaler). */
|
|
145
|
+
agentId: string;
|
|
146
|
+
/** Orchestrator WS URL the instance connects back to. */
|
|
147
|
+
orchestratorUrl: string;
|
|
148
|
+
/** Labels the token authorizes. */
|
|
149
|
+
labels: string[];
|
|
150
|
+
}
|
|
151
|
+
export interface ScalerApi {
|
|
152
|
+
/**
|
|
153
|
+
* Exchange a single-use claim code — delivered on a `kici.scaler.scale-up`
|
|
154
|
+
* event to a provisioning workflow — for freshly minted ephemeral agent
|
|
155
|
+
* credentials. Boot a cloud instance whose agent registers with the returned
|
|
156
|
+
* `agentId` and `agentToken`, and the pending bound job runs on it. The token
|
|
157
|
+
* is minted lazily on this call and never appears in the persisted event log.
|
|
158
|
+
*/
|
|
159
|
+
claimAgentCredentials(claimCode: string): Promise<ClaimedAgentCredentials>;
|
|
160
|
+
}
|
|
139
161
|
export interface KiciApi {
|
|
140
162
|
/** Query orchestrator infrastructure (scalers, agents). */
|
|
141
163
|
infrastructure: InfrastructureApi;
|
|
@@ -143,10 +165,14 @@ export interface KiciApi {
|
|
|
143
165
|
inventory: InventoryApi;
|
|
144
166
|
/** Request short-lived OIDC ID tokens for the current job (build provenance). */
|
|
145
167
|
oidc: OidcApi;
|
|
168
|
+
/** Forge-typed git credentials for the current job. */
|
|
169
|
+
git: GitApi;
|
|
146
170
|
/** Host-lifecycle operations on the agent's own host (e.g. reboot). */
|
|
147
171
|
host: HostApi;
|
|
148
172
|
/** Fresh-box bootstrap bring-up (init-runner over SSH, pre-boot unlock). */
|
|
149
173
|
bootstrap: BootstrapApi;
|
|
174
|
+
/** Event-scaler provisioning: claim ephemeral agent credentials. */
|
|
175
|
+
scaler: ScalerApi;
|
|
150
176
|
}
|
|
151
177
|
/**
|
|
152
178
|
* Low-level transport function used to implement KiciApi.
|
package/dist/api-types.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import "./rolldown-runtime-ClRpJifh.js";
|
|
2
2
|
import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
|
|
3
|
+
import { GIT_CREDENTIAL_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/git-credential-relay";
|
|
3
4
|
//#region src/api-types.ts
|
|
4
5
|
/**
|
|
5
6
|
* Typed KiCI API available to workflows via ctx.kici.
|
|
@@ -37,6 +38,23 @@ function buildKiciApi(transport, jobCtx) {
|
|
|
37
38
|
audience: opts.audience
|
|
38
39
|
});
|
|
39
40
|
} },
|
|
41
|
+
git: { github: { getToken: (opts) => {
|
|
42
|
+
if (!jobCtx) return Promise.reject(/* @__PURE__ */ new Error("ctx.kici.git.github.getToken() is only available inside a running job step"));
|
|
43
|
+
if (opts.repositories.length === 0) return Promise.reject(/* @__PURE__ */ new Error("git.github.getToken requires at least one repository"));
|
|
44
|
+
return transport(GIT_CREDENTIAL_REQUEST_METHOD, {
|
|
45
|
+
jobId: jobCtx.jobId,
|
|
46
|
+
repositories: opts.repositories,
|
|
47
|
+
permissions: opts.permissions,
|
|
48
|
+
...opts.credential ? { credential: opts.credential } : {}
|
|
49
|
+
}).then((raw) => {
|
|
50
|
+
const r = raw;
|
|
51
|
+
return {
|
|
52
|
+
token: r.secret,
|
|
53
|
+
expiresAt: r.expiresAt,
|
|
54
|
+
granted: r.grant
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
} } },
|
|
40
58
|
host: { requestReboot: (opts) => transport("host.requestReboot", { ...opts?.deadlineMs !== void 0 ? { deadlineMs: opts.deadlineMs } : {} }) },
|
|
41
59
|
bootstrap: {
|
|
42
60
|
ensureInitRunner: (targetAgentId) => transport("kici.ensureInitRunner", { targetAgentId }),
|
|
@@ -46,7 +64,8 @@ function buildKiciApi(transport, jobCtx) {
|
|
|
46
64
|
}),
|
|
47
65
|
agentVersionStatus: (targetAgentId) => transport("kici.agentVersionStatus", { targetAgentId }),
|
|
48
66
|
restageAgent: (targetAgentId) => transport("kici.restageAgent", { targetAgentId })
|
|
49
|
-
}
|
|
67
|
+
},
|
|
68
|
+
scaler: { claimAgentCredentials: (claimCode) => transport("scaler.claim-credentials", { claimCode }) }
|
|
50
69
|
};
|
|
51
70
|
}
|
|
52
71
|
//#endregion
|
package/dist/context.d.ts
CHANGED
|
@@ -189,6 +189,27 @@ export interface StepContext<TInputs = Record<string, unknown>> {
|
|
|
189
189
|
* Defaults to false for backward compatibility.
|
|
190
190
|
*/
|
|
191
191
|
isTestRun: boolean;
|
|
192
|
+
/**
|
|
193
|
+
* The job's own checked-out repository.
|
|
194
|
+
*
|
|
195
|
+
* Present for every job that checks out (unlike `sourceRepo` / `workflowRepo`,
|
|
196
|
+
* which exist only for a global workflow). `withWrite` opens a write window
|
|
197
|
+
* for THIS repository, bounded by the repository and by the callback's
|
|
198
|
+
* duration — not by the step: steps running concurrently in the same job can
|
|
199
|
+
* push to the same repository while it is open, but cannot reach a different
|
|
200
|
+
* one. It throws before any git runs when the forge will not grant what was
|
|
201
|
+
* requested.
|
|
202
|
+
*/
|
|
203
|
+
repo?: {
|
|
204
|
+
identifier: string;
|
|
205
|
+
path: string;
|
|
206
|
+
ref?: string;
|
|
207
|
+
sha?: string;
|
|
208
|
+
withWrite(opts: {
|
|
209
|
+
permissions?: Record<string, string>;
|
|
210
|
+
credential?: string;
|
|
211
|
+
}, fn: () => Promise<void>): Promise<void>;
|
|
212
|
+
};
|
|
192
213
|
/**
|
|
193
214
|
* Workflow repo metadata -- only set for global workflows.
|
|
194
215
|
* The registering repo where the workflow code is defined.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { $ as Shell } from 'zx';
|
|
2
|
+
import type { ChangedFilesStatus } from '@kici-dev/engine';
|
|
3
|
+
import type { EventPayload } from './events/event-payloads.js';
|
|
4
|
+
import type { RepoInfo } from './context.js';
|
|
5
|
+
import type { FilterContext } from './filter.js';
|
|
6
|
+
/** Input for {@link createFilterContext}. */
|
|
7
|
+
export interface CreateFilterContextInput {
|
|
8
|
+
/** The repo whose event triggered this evaluation. */
|
|
9
|
+
sourceRepo: RepoInfo;
|
|
10
|
+
/** The repo that registered the workflow. */
|
|
11
|
+
workflowRepo: RepoInfo;
|
|
12
|
+
event: EventPayload | Record<string, unknown>;
|
|
13
|
+
changedFiles?: string[];
|
|
14
|
+
/** Defaults to `'fetched'` — a caller that passes a real list needs no status. */
|
|
15
|
+
changedFilesStatus?: ChangedFilesStatus;
|
|
16
|
+
env?: Record<string, string | undefined>;
|
|
17
|
+
/** zx shell handed to the filter. Defaults to the ambient `$`. */
|
|
18
|
+
$?: typeof Shell;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build a `FilterContext` — the single construction site for the context a
|
|
22
|
+
* workflow's `filter` predicate receives, mirroring {@link createRuleContext}.
|
|
23
|
+
*
|
|
24
|
+
* `changedFiles` is installed through the shared accessor, so it throws
|
|
25
|
+
* `ChangedFilesUnavailableError` when the diff is unavailable instead of
|
|
26
|
+
* reading as an empty list. That matters more here than in a rule: a `false`
|
|
27
|
+
* verdict dispatches none of the workflow's own jobs, and on the
|
|
28
|
+
* organization-wide path it produces no run at all — so a silently-empty diff
|
|
29
|
+
* would suppress the workflow with nothing left to inspect.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createFilterContext(input: CreateFilterContextInput): FilterContext;
|
|
32
|
+
//# sourceMappingURL=filter-context.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import "./rolldown-runtime-ClRpJifh.js";
|
|
2
|
+
import { defineChangedFilesGetter, eventTypeOf } from "./rules/changed-files.js";
|
|
3
|
+
import { $ } from "zx";
|
|
4
|
+
//#region src/filter-context.ts
|
|
5
|
+
/**
|
|
6
|
+
* Build a `FilterContext` — the single construction site for the context a
|
|
7
|
+
* workflow's `filter` predicate receives, mirroring {@link createRuleContext}.
|
|
8
|
+
*
|
|
9
|
+
* `changedFiles` is installed through the shared accessor, so it throws
|
|
10
|
+
* `ChangedFilesUnavailableError` when the diff is unavailable instead of
|
|
11
|
+
* reading as an empty list. That matters more here than in a rule: a `false`
|
|
12
|
+
* verdict dispatches none of the workflow's own jobs, and on the
|
|
13
|
+
* organization-wide path it produces no run at all — so a silently-empty diff
|
|
14
|
+
* would suppress the workflow with nothing left to inspect.
|
|
15
|
+
*/
|
|
16
|
+
function createFilterContext(input) {
|
|
17
|
+
const status = input.changedFilesStatus ?? "fetched";
|
|
18
|
+
const base = {
|
|
19
|
+
sourceRepo: input.sourceRepo,
|
|
20
|
+
workflowRepo: input.workflowRepo,
|
|
21
|
+
event: input.event,
|
|
22
|
+
changedFilesStatus: status,
|
|
23
|
+
env: input.env ?? {},
|
|
24
|
+
$: input.$ ?? $
|
|
25
|
+
};
|
|
26
|
+
defineChangedFilesGetter(base, {
|
|
27
|
+
files: input.changedFiles ?? [],
|
|
28
|
+
status,
|
|
29
|
+
eventType: eventTypeOf(input.event)
|
|
30
|
+
});
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { createFilterContext };
|
|
35
|
+
|
|
36
|
+
//# sourceMappingURL=filter-context.js.map
|
package/dist/filter.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { $ as Shell } from 'zx';
|
|
2
|
+
import type { ChangedFilesStatus } from '@kici-dev/engine';
|
|
3
|
+
import type { EventPayload } from './events/event-payloads.js';
|
|
4
|
+
import type { RepoInfo } from './context.js';
|
|
5
|
+
/**
|
|
6
|
+
* Context handed to a workflow's `filter`, on an evaluating agent with the
|
|
7
|
+
* tree(s) on disk. An organization-wide workflow is evaluated once per
|
|
8
|
+
* (event × workflow repo), before any run row exists; a same-repo workflow is
|
|
9
|
+
* evaluated once per dispatch-bound job and once per generator, where
|
|
10
|
+
* `sourceRepo` and `workflowRepo` are the same repo. See `FilterFn` for what
|
|
11
|
+
* that means for the predicate you write, and which jobs it skips.
|
|
12
|
+
*
|
|
13
|
+
* **The context carries no secrets.** Not because nothing has been resolved
|
|
14
|
+
* yet — on the same-repo path a job's bound contexts, their vars and scoped
|
|
15
|
+
* secrets, its context rules and its approval hold are all resolved BEFORE the
|
|
16
|
+
* evaluating job is queued — but because this context deliberately does not
|
|
17
|
+
* carry them. `filter` sees no `scoped_secrets` and no source-repo credentials
|
|
18
|
+
* beyond the clone token, whichever path it runs on.
|
|
19
|
+
*
|
|
20
|
+
* `sourceRepo.path` is an absolute path on the evaluating agent. Its CONTENTS
|
|
21
|
+
* are stable across evaluations and the later sandbox run; the path itself is
|
|
22
|
+
* not (different workDir, possibly a different machine). Read through it; never
|
|
23
|
+
* embed it in a job name, an output, or anything compared across calls.
|
|
24
|
+
*
|
|
25
|
+
* `sourceRepo.ref` and `sourceRepo.sha` are optional on `RepoInfo` and may be
|
|
26
|
+
* absent for an event that carries no single ref — guard before reading them.
|
|
27
|
+
*/
|
|
28
|
+
export interface FilterContext {
|
|
29
|
+
/** The repo whose push triggered this evaluation. */
|
|
30
|
+
sourceRepo: RepoInfo;
|
|
31
|
+
/** The repo that registered the workflow. Identical to `sourceRepo` for a non-global workflow. */
|
|
32
|
+
workflowRepo: RepoInfo;
|
|
33
|
+
/** Normalized event envelope that triggered this evaluation. */
|
|
34
|
+
event: EventPayload;
|
|
35
|
+
/**
|
|
36
|
+
* Files changed in this event (push / pull_request diff, computed from the
|
|
37
|
+
* checkout). Reading this throws `ChangedFilesUnavailableError` when the diff
|
|
38
|
+
* is not available (`changedFilesStatus !== 'fetched'`) — e.g. a
|
|
39
|
+
* schedule/tag/manual event. Guard with `changedFilesStatus` first when a
|
|
40
|
+
* filter runs on such events:
|
|
41
|
+
* `if (ctx.changedFilesStatus !== 'fetched') return true`.
|
|
42
|
+
*
|
|
43
|
+
* The throw is deliberate and mirrors `RuleContext.changedFiles`: a `false`
|
|
44
|
+
* verdict dispatches none of the workflow's own jobs, so a silently-empty
|
|
45
|
+
* list would make a path-based gate suppress it invisibly. How invisibly
|
|
46
|
+
* depends on the path — an organization-wide workflow leaves no run at all,
|
|
47
|
+
* a same-repo one leaves a `success` run carrying only its `__init__*`
|
|
48
|
+
* evaluation jobs, whose log records the verdict.
|
|
49
|
+
*/
|
|
50
|
+
changedFiles: string[];
|
|
51
|
+
/** Availability of `changedFiles` (see `changedFiles`). */
|
|
52
|
+
changedFilesStatus: ChangedFilesStatus;
|
|
53
|
+
/** Environment variables. */
|
|
54
|
+
env: Record<string, string | undefined>;
|
|
55
|
+
/** zx shell executor. */
|
|
56
|
+
$: typeof Shell;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A workflow-level pre-dispatch predicate.
|
|
60
|
+
*
|
|
61
|
+
* **It must be pure and deterministic.** For an organization-wide workflow it is
|
|
62
|
+
* called once per (event × workflow repo). For a same-repo workflow it is called
|
|
63
|
+
* once for every job that reaches dispatch and once for every job generator, so
|
|
64
|
+
* a workflow with ten jobs calls it ten times for one event, each on its own
|
|
65
|
+
* agent with its own checkout and its own `ctx.$` shell. Two consequences to
|
|
66
|
+
* design for: anything the predicate does — an API call, a shell command, a
|
|
67
|
+
* write — happens that many times, so keep it cheap and side-effect free; and if
|
|
68
|
+
* it can return different answers for the same event, the workflow will
|
|
69
|
+
* partially dispatch, running some jobs and not others.
|
|
70
|
+
*
|
|
71
|
+
* "Reaches dispatch" excludes two same-repo cases: a job **held for approval**
|
|
72
|
+
* and a job **rejected by a context rule** are never filtered. Each already has
|
|
73
|
+
* a gate — the hold or the rule — so an approved job dispatches with no filter
|
|
74
|
+
* verdict having been taken. A path filter therefore cannot stop an approval
|
|
75
|
+
* request for a job the change does not concern.
|
|
76
|
+
*
|
|
77
|
+
* Decide from `ctx` alone — the event, the changed files, and the checked-out
|
|
78
|
+
* tree — and the same event always yields the same verdict.
|
|
79
|
+
*/
|
|
80
|
+
export type FilterFn = (ctx: FilterContext) => boolean | Promise<boolean>;
|
|
81
|
+
//# sourceMappingURL=filter.d.ts.map
|
package/dist/filter.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import "./rolldown-runtime-ClRpJifh.js";
|
|
2
|
+
import { workflow } from "./workflow.js";
|
|
3
|
+
import { job } from "./job.js";
|
|
4
|
+
import { step } from "./step.js";
|
|
5
|
+
import { describe, expectTypeOf, it } from "vitest";
|
|
6
|
+
//#region src/filter.test-d.ts
|
|
7
|
+
const noop = job("noop", {
|
|
8
|
+
runsOn: "linux",
|
|
9
|
+
steps: [step("x", { run: async () => {} })]
|
|
10
|
+
});
|
|
11
|
+
describe("workflow filter — type level", () => {
|
|
12
|
+
it("rejects a non-function filter", () => {
|
|
13
|
+
workflow("org-ci", {
|
|
14
|
+
jobs: [noop],
|
|
15
|
+
filter: "yes"
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it("accepts a function filter", () => {
|
|
19
|
+
const filter = ({ sourceRepo }) => sourceRepo.identifier !== "a/b";
|
|
20
|
+
const wf = workflow("org-ci", {
|
|
21
|
+
jobs: [noop],
|
|
22
|
+
filter
|
|
23
|
+
});
|
|
24
|
+
expectTypeOf(wf.filter).toEqualTypeOf();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
export {};
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=filter.test-d.js.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge-typed git surface for workflow authors.
|
|
3
|
+
*
|
|
4
|
+
* The forge is a TYPE PARAMETER and nothing more. It is erased at runtime:
|
|
5
|
+
* `kici.git.clone<'github'>()` against a Forgejo source compiles cleanly, the
|
|
6
|
+
* requested permissions are ignored, and the result reports
|
|
7
|
+
* `granted: { scoped: false }`. It buys autocomplete and a compile error on a
|
|
8
|
+
* wrong-shaped permission object; it buys no safety. The runtime `granted`
|
|
9
|
+
* value is the only source of truth about what a credential can do.
|
|
10
|
+
*/
|
|
11
|
+
/** Forges that can back a git credential. Mirrors the engine's `ForgeName`. */
|
|
12
|
+
export type ForgeName = 'github' | 'gitlab' | 'bitbucket' | 'generic';
|
|
13
|
+
/**
|
|
14
|
+
* GitHub repository permissions.
|
|
15
|
+
*
|
|
16
|
+
* DELIBERATE CARVE-OUT from the enums-over-hardcoded-strings rule: the known
|
|
17
|
+
* keys below are typed so editors autocomplete them, but an index signature
|
|
18
|
+
* lets an unknown key through. GitHub adds permissions over time, and a closed
|
|
19
|
+
* enum would mean a permission shipped yesterday needs an SDK release — and the
|
|
20
|
+
* SDK is compat-protected and rides the single-version release train. Unknown
|
|
21
|
+
* keys are passed to GitHub verbatim for it to accept or reject.
|
|
22
|
+
*/
|
|
23
|
+
export interface GitHubPermissions {
|
|
24
|
+
contents?: 'read' | 'write';
|
|
25
|
+
metadata?: 'read';
|
|
26
|
+
workflows?: 'write';
|
|
27
|
+
actions?: 'read' | 'write';
|
|
28
|
+
pull_requests?: 'read' | 'write';
|
|
29
|
+
issues?: 'read' | 'write';
|
|
30
|
+
checks?: 'read' | 'write';
|
|
31
|
+
statuses?: 'read' | 'write';
|
|
32
|
+
deployments?: 'read' | 'write';
|
|
33
|
+
packages?: 'read' | 'write';
|
|
34
|
+
[permission: string]: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Options for a write elevation, shaped by the forge.
|
|
38
|
+
*
|
|
39
|
+
* `WriteOptions<'generic'>` carries no permission fields at all: a static
|
|
40
|
+
* credential has nothing to request, because an SSH key or PAT is read-write or
|
|
41
|
+
* it is not.
|
|
42
|
+
*/
|
|
43
|
+
export type WriteOptions<F extends ForgeName = 'github'> = F extends 'github' ? {
|
|
44
|
+
permissions: GitHubPermissions;
|
|
45
|
+
} : Record<never, never>;
|
|
46
|
+
/** What a credential turned out to be able to do. Never an echo of the request. */
|
|
47
|
+
export type GitGrant = {
|
|
48
|
+
scoped: false;
|
|
49
|
+
} | {
|
|
50
|
+
scoped: true;
|
|
51
|
+
permissions: Record<string, string>;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* One half of a credential field pair. Exactly one form is set: a qualified
|
|
55
|
+
* `<context>:<secret-name>` reference resolved from the secrets backend, or
|
|
56
|
+
* material supplied at runtime.
|
|
57
|
+
*
|
|
58
|
+
* The field NAME is the discriminator, following the convention `workflow.ts`
|
|
59
|
+
* already sets with `registries[].tokenSecret` and `isQualifiedSecretRef`.
|
|
60
|
+
*/
|
|
61
|
+
export type Sourced<Name extends string> = {
|
|
62
|
+
[K in `${Name}Secret`]: string;
|
|
63
|
+
} | {
|
|
64
|
+
[K in `${Name}Value`]: string;
|
|
65
|
+
};
|
|
66
|
+
/** Where credential material comes from, for each supported credential shape. */
|
|
67
|
+
export type GitCredentialRef = ({
|
|
68
|
+
kind: 'app';
|
|
69
|
+
} & Sourced<'appId'> & Sourced<'installationId'> & Sourced<'privateKey'>) | ({
|
|
70
|
+
kind: 'token';
|
|
71
|
+
user?: string;
|
|
72
|
+
} & Sourced<'token'>) | ({
|
|
73
|
+
kind: 'ssh';
|
|
74
|
+
} & Sourced<'privateKey'>);
|
|
75
|
+
/** Named credentials for a job. `default` is used when a call names none. */
|
|
76
|
+
export type GitCredentialMap = Record<string, GitCredentialRef>;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a call site's credential.
|
|
79
|
+
*
|
|
80
|
+
* Order: an explicit per-call value wins; otherwise `default` from the job map;
|
|
81
|
+
* otherwise undefined, which means the source credential — all a read needs.
|
|
82
|
+
*
|
|
83
|
+
* An unknown name THROWS rather than falling back to `default`: silently using
|
|
84
|
+
* a different credential than the author named is precisely the confusion this
|
|
85
|
+
* map exists to remove.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveCredential(perCall: string | GitCredentialRef | undefined, map: GitCredentialMap | undefined): GitCredentialRef | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Every credential field names a secret. Catch the easy, silent mistake of
|
|
90
|
+
* pasting the credential itself — which would commit it to a git repository.
|
|
91
|
+
*/
|
|
92
|
+
export declare function assertSecretName(value: string, field: string, subject?: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* Private-registry credentials for pulling a job's container image.
|
|
95
|
+
*
|
|
96
|
+
* Built from `Sourced<Name>` so "this field names a secret" has ONE spelling
|
|
97
|
+
* across the SDK — the same one `gitCredentials` uses. `username` may be a
|
|
98
|
+
* plain string (a registry username is not a secret); the token may not be.
|
|
99
|
+
*/
|
|
100
|
+
export type ContainerRegistryAuth = Sourced<'token'> & {
|
|
101
|
+
/** Plain registry username. Mutually exclusive with `usernameSecret`. */
|
|
102
|
+
username?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Registry host these credentials belong to (e.g. `reg.internal:5000`).
|
|
105
|
+
*
|
|
106
|
+
* Optional with `container.image`, where it is derived from the image
|
|
107
|
+
* reference. REQUIRED with `container.dockerfile`: the base image is named
|
|
108
|
+
* inside the Dockerfile, so there is nothing to derive it from.
|
|
109
|
+
*/
|
|
110
|
+
registry?: string;
|
|
111
|
+
} & Partial<Sourced<'username'>>;
|
|
112
|
+
/** A checked-out repository. The forge travels with the handle. */
|
|
113
|
+
export interface RepoHandle<F extends ForgeName = 'github'> {
|
|
114
|
+
/** `owner/repo`. */
|
|
115
|
+
identifier: string;
|
|
116
|
+
/** Absolute path to the working tree. */
|
|
117
|
+
path: string;
|
|
118
|
+
ref?: string;
|
|
119
|
+
sha?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Run `fn` with write credentials for THIS repository.
|
|
122
|
+
*
|
|
123
|
+
* The grant is scoped to this repository and to the duration of `fn` — NOT to
|
|
124
|
+
* the calling step. The agent runs one process per job and `parallel()` runs
|
|
125
|
+
* its children inside it, so a concurrent sibling step can push to the same
|
|
126
|
+
* repository while the grant is live. It cannot reach a different repository.
|
|
127
|
+
*
|
|
128
|
+
* Throws at entry — before any git runs — when the forge grants less than was
|
|
129
|
+
* requested, naming the missing permission.
|
|
130
|
+
*/
|
|
131
|
+
withWrite(opts: WriteOptions<F>, fn: () => Promise<void>): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
/** Result of an explicit token request. */
|
|
134
|
+
export interface GitTokenResult {
|
|
135
|
+
token: string;
|
|
136
|
+
expiresAt: string | null;
|
|
137
|
+
granted: GitGrant;
|
|
138
|
+
}
|
|
139
|
+
/** Forge-specific minting. Only minted shapes appear here; static ones have nothing to mint. */
|
|
140
|
+
export interface GitHubApi {
|
|
141
|
+
/**
|
|
142
|
+
* Mint a token as a VALUE, for the forge API, the `gh` CLI, or a third-party
|
|
143
|
+
* tool. Auto-masked in step logs; job-bound; throws outside a running step.
|
|
144
|
+
*
|
|
145
|
+
* For git operations prefer `handle.withWrite()`, which never puts a token in
|
|
146
|
+
* the step environment. `gh` does not read git credential helpers, which is
|
|
147
|
+
* why this exists.
|
|
148
|
+
*/
|
|
149
|
+
getToken(opts: {
|
|
150
|
+
repositories: string[];
|
|
151
|
+
permissions: GitHubPermissions;
|
|
152
|
+
/**
|
|
153
|
+
* Name an entry in the job's `gitCredentials` map. Omit to use `default`;
|
|
154
|
+
* omit both and the source credential applies.
|
|
155
|
+
*/
|
|
156
|
+
credential?: string;
|
|
157
|
+
}): Promise<GitTokenResult>;
|
|
158
|
+
}
|
|
159
|
+
export interface GitApi {
|
|
160
|
+
github: GitHubApi;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=git-types.d.ts.map
|