@savvy-web/github-action-builder 2.1.1 → 2.2.1

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.
@@ -1,7 +1,21 @@
1
- import { Context, Schema } from "effect";
1
+ import { ActionYmlPathError, PersistLocalError } from "../errors.js";
2
+ import { Context, Effect, Layer, Schema } from "effect";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { Yaml } from "@effected/yaml";
2
7
 
3
8
  //#region src/services/persist-local.ts
4
9
  /**
10
+ * PersistLocalService - Effect service for copying build output to a local action directory.
11
+ *
12
+ * @remarks
13
+ * Provides smart-sync of action.yml and dist/ to a local directory
14
+ * for testing with nektos/act.
15
+ *
16
+ * @internal
17
+ */
18
+ /**
5
19
  * Options for the persist operation.
6
20
  * @public
7
21
  */
@@ -27,11 +41,209 @@ const PersistLocalResultSchema = Schema.Struct({
27
41
  error: Schema.optional(Schema.String)
28
42
  });
29
43
  /**
44
+ * Compute SHA-256 hash of a file's contents.
45
+ */
46
+ function fileHash(filePath) {
47
+ const content = readFileSync(filePath);
48
+ return createHash("sha256").update(content).digest("hex");
49
+ }
50
+ /**
51
+ * Sync a single file from src to dest using hash comparison.
52
+ * Returns true if the file was copied, false if skipped.
53
+ */
54
+ function syncFile(src, dest) {
55
+ if (existsSync(dest)) {
56
+ if (fileHash(src) === fileHash(dest)) return false;
57
+ }
58
+ mkdirSync(dirname(dest), { recursive: true });
59
+ copyFileSync(src, dest);
60
+ return true;
61
+ }
62
+ /**
63
+ * Recursively collect all file paths relative to a base directory.
64
+ */
65
+ function walkDirectory(dir, base = dir) {
66
+ const files = [];
67
+ if (!existsSync(dir)) return files;
68
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
69
+ const fullPath = join(dir, entry.name);
70
+ if (entry.isDirectory()) files.push(...walkDirectory(fullPath, base));
71
+ else files.push(relative(base, fullPath));
72
+ }
73
+ return files;
74
+ }
75
+ /**
76
+ * Smart-sync a directory: copy changed files, remove stale dest files.
77
+ */
78
+ function syncDirectory(srcDir, destDir) {
79
+ const stats = {
80
+ copied: 0,
81
+ skipped: 0
82
+ };
83
+ const srcFiles = walkDirectory(srcDir);
84
+ for (const relPath of srcFiles) if (syncFile(join(srcDir, relPath), join(destDir, relPath))) stats.copied++;
85
+ else stats.skipped++;
86
+ const srcFileSet = new Set(srcFiles);
87
+ const destFiles = walkDirectory(destDir);
88
+ for (const relPath of destFiles) if (!srcFileSet.has(relPath)) {
89
+ rmSync(join(destDir, relPath), { force: true });
90
+ let parent = dirname(join(destDir, relPath));
91
+ while (parent !== destDir && existsSync(parent)) if (readdirSync(parent).length === 0) {
92
+ rmSync(parent, { recursive: true });
93
+ parent = dirname(parent);
94
+ } else break;
95
+ }
96
+ return stats;
97
+ }
98
+ /**
99
+ * Validate that action.yml runs paths resolve correctly relative to the destination.
100
+ */
101
+ function validateActionYmlPaths(actionYmlPath, destDir) {
102
+ return Effect.gen(function* () {
103
+ if (!existsSync(actionYmlPath)) return;
104
+ const content = readFileSync(actionYmlPath, "utf8");
105
+ const parsed = yield* Yaml.parse(content).pipe(Effect.catch(() => Effect.succeed(null)));
106
+ if (!parsed?.runs) return;
107
+ for (const entryType of [
108
+ "main",
109
+ "pre",
110
+ "post"
111
+ ]) {
112
+ const specifiedPath = parsed.runs[entryType];
113
+ if (!specifiedPath) continue;
114
+ const expectedPath = resolve(destDir, specifiedPath);
115
+ if (!existsSync(expectedPath)) return yield* Effect.fail(new ActionYmlPathError({
116
+ entryType,
117
+ specifiedPath,
118
+ expectedPath
119
+ }));
120
+ }
121
+ });
122
+ }
123
+ const ACTRC_CONTENT = `--container-architecture linux/amd64
124
+ -W .github/workflows/act-test.yml
125
+ `;
126
+ const ACT_WORKFLOW_CONTENT = `name: Local Test
127
+ on:
128
+ workflow_dispatch:
129
+
130
+ jobs:
131
+ test:
132
+ runs-on: ubuntu-latest
133
+ steps:
134
+ - uses: actions/checkout@v7
135
+ - uses: ./.github/actions/local
136
+ `;
137
+ function formatPersistResult(result) {
138
+ const lines = [];
139
+ if (result.success) {
140
+ lines.push("Persist Local Summary:");
141
+ lines.push(` Output: ${result.outputPath}`);
142
+ lines.push(` Files copied: ${result.filesCopied}`);
143
+ lines.push(` Files skipped (unchanged): ${result.filesSkipped}`);
144
+ if (result.actTemplateGenerated) lines.push(" Act template files generated");
145
+ } else lines.push(`Persist Local Failed: ${result.error}`);
146
+ return lines.join("\n");
147
+ }
148
+ /**
30
149
  * PersistLocalService key for dependency injection.
31
150
  *
32
151
  * @public
33
152
  */
34
- var PersistLocalService = class extends Context.Service()("PersistLocalService") {};
153
+ var PersistLocalService = class extends Context.Service()("PersistLocalService") {
154
+ /**
155
+ * Production implementation of {@link PersistLocalService}.
156
+ *
157
+ * @public
158
+ */
159
+ static layer = Layer.succeed(this, {
160
+ persist: (config, options = {}) => Effect.gen(function* () {
161
+ const cwd = options.cwd ?? process.cwd();
162
+ const outputPath = resolve(cwd, config.persistLocal.path);
163
+ if (!config.persistLocal.enabled) return {
164
+ success: true,
165
+ filesCopied: 0,
166
+ filesSkipped: 0,
167
+ actTemplateGenerated: false,
168
+ outputPath
169
+ };
170
+ yield* Effect.try({
171
+ try: () => mkdirSync(outputPath, { recursive: true }),
172
+ /* v8 ignore next 5 - error branch requires fs permission failures */
173
+ catch: (error) => new PersistLocalError({
174
+ path: outputPath,
175
+ cause: error
176
+ })
177
+ });
178
+ let totalCopied = 0;
179
+ let totalSkipped = 0;
180
+ const actionYmlSrc = resolve(cwd, "action.yml");
181
+ const actionYmlDest = resolve(outputPath, "action.yml");
182
+ if (existsSync(actionYmlSrc)) if (yield* Effect.try({
183
+ try: () => syncFile(actionYmlSrc, actionYmlDest),
184
+ /* v8 ignore next 5 - error branch requires fs permission failures */
185
+ catch: (error) => new PersistLocalError({
186
+ path: actionYmlSrc,
187
+ cause: error
188
+ })
189
+ })) totalCopied++;
190
+ else totalSkipped++;
191
+ else if (existsSync(actionYmlDest)) rmSync(actionYmlDest, { force: true });
192
+ const distSrc = resolve(cwd, "dist");
193
+ if (existsSync(distSrc) && statSync(distSrc).isDirectory()) {
194
+ const distStats = yield* Effect.try({
195
+ try: () => syncDirectory(distSrc, resolve(outputPath, "dist")),
196
+ /* v8 ignore next 5 - error branch requires fs permission failures */
197
+ catch: (error) => new PersistLocalError({
198
+ path: distSrc,
199
+ cause: error
200
+ })
201
+ });
202
+ totalCopied += distStats.copied;
203
+ totalSkipped += distStats.skipped;
204
+ }
205
+ yield* validateActionYmlPaths(resolve(outputPath, "action.yml"), outputPath);
206
+ let actTemplateGenerated = false;
207
+ if (config.persistLocal.actTemplate) {
208
+ const actrcPath = resolve(cwd, ".actrc");
209
+ const actWorkflowPath = resolve(cwd, ".github/workflows/act-test.yml");
210
+ if (!existsSync(actrcPath)) {
211
+ yield* Effect.try({
212
+ try: () => writeFileSync(actrcPath, ACTRC_CONTENT, "utf8"),
213
+ /* v8 ignore next 5 - error branch requires fs permission failures */
214
+ catch: (error) => new PersistLocalError({
215
+ path: actrcPath,
216
+ cause: error
217
+ })
218
+ });
219
+ actTemplateGenerated = true;
220
+ }
221
+ if (!existsSync(actWorkflowPath)) {
222
+ yield* Effect.try({
223
+ try: () => {
224
+ mkdirSync(dirname(actWorkflowPath), { recursive: true });
225
+ writeFileSync(actWorkflowPath, ACT_WORKFLOW_CONTENT, "utf8");
226
+ },
227
+ /* v8 ignore next 5 - error branch requires fs permission failures */
228
+ catch: (error) => new PersistLocalError({
229
+ path: actWorkflowPath,
230
+ cause: error
231
+ })
232
+ });
233
+ actTemplateGenerated = true;
234
+ }
235
+ }
236
+ return {
237
+ success: true,
238
+ filesCopied: totalCopied,
239
+ filesSkipped: totalSkipped,
240
+ actTemplateGenerated,
241
+ outputPath
242
+ };
243
+ }),
244
+ formatResult: formatPersistResult
245
+ });
246
+ };
35
247
 
36
248
  //#endregion
37
249
  export { PersistLocalResultSchema, PersistLocalRunnerOptionsSchema, PersistLocalService };
@@ -1,8 +1,23 @@
1
+ import { ActionYmlMissing, ActionYmlSchemaError, ActionYmlSyntaxError, MainEntryMissing, ValidationFailed } from "../errors.js";
1
2
  import { OptionalPathLikeSchema } from "../schemas/path.js";
2
- import { Context, Schema } from "effect";
3
+ import { ConfigService } from "./config.js";
4
+ import { ActionYml } from "../schemas/action-yml.js";
5
+ import { Context, Effect, Layer, Result, Schema } from "effect";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { Yaml } from "@effected/yaml";
3
9
 
4
10
  //#region src/services/validation.ts
5
11
  /**
12
+ * ValidationService - Effect service for validation.
13
+ *
14
+ * @remarks
15
+ * Provides validation of configuration, entry points, and action.yml
16
+ * using Effect's service pattern with Context.Service.
17
+ *
18
+ * @internal
19
+ */
20
+ /**
6
21
  * Options for validation.
7
22
  * @public
8
23
  */
@@ -66,6 +81,29 @@ const ActionYmlResultSchema = Schema.Struct({
66
81
  /** Validation warnings. */
67
82
  warnings: Schema.Array(ValidationWarningSchema)
68
83
  });
84
+ /* v8 ignore start - CI environment detection has multiple env var formats */
85
+ /** Check if running in CI environment. */
86
+ const isCI = () => process.env.CI === "true" || process.env.CI === "1" || process.env.GITHUB_ACTIONS === "true";
87
+ /** Resolve strict mode from config or environment. */
88
+ const resolveStrict = (configStrict) => configStrict ?? isCI();
89
+ /* v8 ignore stop */
90
+ /** Create a validation warning, omitting undefined file. */
91
+ const makeWarning = (code, message, suggestion, file) => file !== void 0 ? {
92
+ code,
93
+ message,
94
+ suggestion,
95
+ file
96
+ } : {
97
+ code,
98
+ message,
99
+ suggestion
100
+ };
101
+ /** Format schema parse errors. */
102
+ /* v8 ignore start - only called for schema validation errors */
103
+ const formatSchemaErrors = (error, filePath) => [{
104
+ path: filePath,
105
+ message: error.message
106
+ }];
69
107
  /**
70
108
  * ValidationService key for dependency injection.
71
109
  *
@@ -91,7 +129,190 @@ const ActionYmlResultSchema = Schema.Struct({
91
129
  *
92
130
  * @public
93
131
  */
94
- var ValidationService = class extends Context.Service()("ValidationService") {};
132
+ var ValidationService = class extends Context.Service()("ValidationService") {
133
+ /**
134
+ * Production implementation of {@link ValidationService}.
135
+ *
136
+ * @remarks
137
+ * Depends on {@link ConfigService}.
138
+ *
139
+ * @public
140
+ */
141
+ static layer = Layer.effect(this, Effect.gen(function* () {
142
+ const configService = yield* ConfigService;
143
+ /** Read and parse action.yml file. */
144
+ const readActionYml = (path) => Effect.gen(function* () {
145
+ if (!existsSync(path)) return yield* new ActionYmlMissing({ cwd: path });
146
+ const content = yield* Effect.try({
147
+ try: () => readFileSync(path, "utf8"),
148
+ /* v8 ignore next */
149
+ catch: () => new ActionYmlSyntaxError({
150
+ path,
151
+ message: "Failed to read file"
152
+ })
153
+ });
154
+ const parsed = yield* Yaml.parse(content).pipe(
155
+ /* v8 ignore next 5 - requires malformed YAML */
156
+ Effect.mapError((error) => new ActionYmlSyntaxError({
157
+ path,
158
+ message: error.message
159
+ }))
160
+ );
161
+ /* v8 ignore start - requires non-object YAML (e.g., scalar or array) */
162
+ if (!parsed || typeof parsed !== "object") return yield* new ActionYmlSyntaxError({
163
+ path,
164
+ message: "action.yml must be an object"
165
+ });
166
+ /* v8 ignore stop */
167
+ return parsed;
168
+ });
169
+ /** Validate parsed content against ActionYml schema. */
170
+ /* v8 ignore start - schema validation error branch */
171
+ const validateSchema = (parsed, path) => Effect.gen(function* () {
172
+ const result = yield* Effect.result(Schema.decodeUnknownEffect(ActionYml)(parsed));
173
+ if (Result.isFailure(result)) return yield* new ActionYmlSchemaError({
174
+ path,
175
+ errors: formatSchemaErrors(result.failure, path)
176
+ });
177
+ return result.success;
178
+ });
179
+ /* v8 ignore stop */
180
+ /** Check for recommended fields and generate warnings. */
181
+ /* v8 ignore start - recommendation checks have many branches */
182
+ const checkRecommendations = (content, filePath) => {
183
+ const warnings = [];
184
+ if (!content.branding) warnings.push(makeWarning("ACTION_YML_NO_BRANDING", "No branding configuration found", "Add branding.icon and branding.color for better marketplace visibility", filePath));
185
+ else {
186
+ const branding = content.branding;
187
+ if (!branding.icon) warnings.push(makeWarning("ACTION_YML_NO_BRANDING_ICON", "Branding icon not specified", "Add branding.icon for better marketplace visibility", filePath));
188
+ if (!branding.color) warnings.push(makeWarning("ACTION_YML_NO_BRANDING_COLOR", "Branding color not specified", "Add branding.color for better marketplace visibility", filePath));
189
+ }
190
+ if (content.inputs) {
191
+ const inputs = content.inputs;
192
+ for (const [name, input] of Object.entries(inputs)) if (!input.description) warnings.push(makeWarning("ACTION_YML_INPUT_NO_DESCRIPTION", `Input '${name}' has no description`, `Add a description for the '${name}' input`, filePath));
193
+ }
194
+ if (content.outputs) {
195
+ const outputs = content.outputs;
196
+ for (const [name, output] of Object.entries(outputs)) if (!output.description) warnings.push(makeWarning("ACTION_YML_OUTPUT_NO_DESCRIPTION", `Output '${name}' has no description`, `Add a description for the '${name}' output`, filePath));
197
+ }
198
+ return warnings;
199
+ };
200
+ /* v8 ignore stop */
201
+ /** Validate action.yml file completely. */
202
+ const validateActionYml = (path) => Effect.gen(function* () {
203
+ const parsed = yield* readActionYml(path);
204
+ return {
205
+ valid: true,
206
+ content: yield* validateSchema(parsed, path),
207
+ errors: [],
208
+ warnings: checkRecommendations(parsed, path)
209
+ };
210
+ });
211
+ /** Check entry points exist. */
212
+ const checkEntries = (config, cwd) => Effect.gen(function* () {
213
+ const errors = [];
214
+ const entriesConfig = { main: config.entries.main };
215
+ if (config.entries.pre) entriesConfig.pre = config.entries.pre;
216
+ if (config.entries.post) entriesConfig.post = config.entries.post;
217
+ const result = yield* Effect.result(configService.detectEntries(cwd, entriesConfig));
218
+ /* v8 ignore start - error branch requires missing main entry */
219
+ if (Result.isFailure(result) && result.failure instanceof MainEntryMissing) errors.push({
220
+ code: "MAIN_ENTRY_MISSING",
221
+ message: `Main entry point not found: ${result.failure.expectedPath}`,
222
+ file: result.failure.expectedPath,
223
+ suggestion: "Create src/main.ts or specify a different path in config"
224
+ });
225
+ /* v8 ignore stop */
226
+ return errors;
227
+ });
228
+ /** Check action.yml and collect errors/warnings. */
229
+ /* v8 ignore start - action.yml validation has many error branches */
230
+ const checkActionYml = (config, cwd) => Effect.gen(function* () {
231
+ const errors = [];
232
+ const warnings = [];
233
+ if (!config.validation.requireActionYml) return {
234
+ errors,
235
+ warnings
236
+ };
237
+ const actionYmlPath = resolve(cwd, "action.yml");
238
+ const result = yield* Effect.result(validateActionYml(actionYmlPath));
239
+ if (Result.isFailure(result)) {
240
+ const error = result.failure;
241
+ if (error instanceof ActionYmlMissing) warnings.push({
242
+ code: "ACTION_YML_MISSING",
243
+ message: "action.yml not found",
244
+ file: actionYmlPath,
245
+ suggestion: "Create action.yml to define your action metadata"
246
+ });
247
+ else if (error instanceof ActionYmlSyntaxError) errors.push({
248
+ code: "ACTION_YML_SYNTAX_ERROR",
249
+ message: error.message,
250
+ file: error.path
251
+ });
252
+ else if (error instanceof ActionYmlSchemaError) for (const schemaError of error.errors) errors.push({
253
+ code: "ACTION_YML_SCHEMA_ERROR",
254
+ message: schemaError.message,
255
+ file: error.path
256
+ });
257
+ } else warnings.push(...result.success.warnings);
258
+ return {
259
+ errors,
260
+ warnings
261
+ };
262
+ });
263
+ /* v8 ignore stop */
264
+ return {
265
+ validate: (config, options = {}) => Effect.gen(function* () {
266
+ const cwd = options.cwd ?? process.cwd();
267
+ const strict = resolveStrict(options.strict ?? config.validation.strict);
268
+ const entryErrors = yield* checkEntries(config, cwd);
269
+ const actionYmlResult = yield* checkActionYml(config, cwd);
270
+ const errors = [...entryErrors, ...actionYmlResult.errors];
271
+ const warnings = [...actionYmlResult.warnings];
272
+ const valid = errors.length === 0 && (!strict || warnings.length === 0);
273
+ /* v8 ignore start - strict mode branch requires CI environment */
274
+ if (strict && warnings.length > 0 && errors.length === 0) return yield* new ValidationFailed({
275
+ errorCount: 0,
276
+ warningCount: warnings.length,
277
+ message: "Warnings treated as errors in strict mode"
278
+ });
279
+ /* v8 ignore stop */
280
+ return {
281
+ valid,
282
+ errors,
283
+ warnings
284
+ };
285
+ }),
286
+ validateActionYml,
287
+ /* v8 ignore start - formatting function tested via integration */
288
+ formatResult: (result) => {
289
+ const lines = [];
290
+ if (result.errors.length > 0) {
291
+ lines.push("Errors:");
292
+ for (const error of result.errors) {
293
+ lines.push(` ✗ ${error.message}`);
294
+ if (error.suggestion) lines.push(` → ${error.suggestion}`);
295
+ }
296
+ }
297
+ if (result.warnings.length > 0) {
298
+ if (lines.length > 0) lines.push("");
299
+ lines.push("Warnings:");
300
+ for (const warning of result.warnings) {
301
+ lines.push(` ⚠ ${warning.message}`);
302
+ if (warning.suggestion) lines.push(` → ${warning.suggestion}`);
303
+ }
304
+ }
305
+ if (result.valid && result.errors.length === 0 && result.warnings.length === 0) lines.push("✓ All checks passed");
306
+ return lines.join("\n");
307
+ },
308
+ /* v8 ignore stop */
309
+ /* v8 ignore next 2 - environment detection */
310
+ isCI: () => Effect.succeed(isCI()),
311
+ /* v8 ignore next */
312
+ isStrict: (configStrict) => Effect.succeed(resolveStrict(configStrict))
313
+ };
314
+ }));
315
+ };
95
316
 
96
317
  //#endregion
97
318
  export { ActionYmlResultSchema, ValidateOptionsSchema, ValidationErrorSchema, ValidationResultSchema, ValidationService, ValidationWarningSchema };