@effected/github-actions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { WorkflowCommand } from "./WorkflowCommand.js";
3
+ import { Console, Context, Effect, Inspectable, Layer, LogLevel, Logger, References } from "effect";
4
+
5
+ //#region src/ActionLogger.ts
6
+ /**
7
+ * Render a log message, which arrives as an array of the values passed to
8
+ * `Effect.log*`.
9
+ *
10
+ * @remarks
11
+ * `toStringUnknown` is called with zero indentation on purpose: a pretty-printed
12
+ * object would span lines, and a workflow command escapes every newline, so the
13
+ * indented form buys nothing and costs readability.
14
+ */
15
+ const formatMessage = (message) => {
16
+ return (Array.isArray(message) ? message : [message]).map((part) => Inspectable.toStringUnknown(part, 0)).join(" ");
17
+ };
18
+ /** A log annotation read as text, when it is text. */
19
+ const textAnnotation = (record, key) => {
20
+ const value = record[key];
21
+ return typeof value === "string" ? value : void 0;
22
+ };
23
+ /** A log annotation read as a number, accepting the string form the runner produces. */
24
+ const numericAnnotation = (record, key) => {
25
+ const value = record[key];
26
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
27
+ if (typeof value === "string" && value !== "") {
28
+ const parsed = Number(value);
29
+ return Number.isFinite(parsed) ? parsed : void 0;
30
+ }
31
+ };
32
+ /**
33
+ * Project log annotations onto the annotation vocabulary.
34
+ *
35
+ * @remarks
36
+ * The keys are the readable ones from {@link AnnotationProperties}, not GitHub's
37
+ * abbreviated wire names — one vocabulary per package, with the abbreviation
38
+ * confined to {@link WorkflowCommand}. Undefined fields are omitted rather than
39
+ * set, so a caller that annotated nothing renders a bare command.
40
+ */
41
+ const readAnnotations = (record) => {
42
+ const title = textAnnotation(record, "title");
43
+ const file = textAnnotation(record, "file");
44
+ const startLine = numericAnnotation(record, "startLine");
45
+ const endLine = numericAnnotation(record, "endLine");
46
+ const startColumn = numericAnnotation(record, "startColumn");
47
+ const endColumn = numericAnnotation(record, "endColumn");
48
+ return {
49
+ ...title === void 0 ? {} : { title },
50
+ ...file === void 0 ? {} : { file },
51
+ ...startLine === void 0 ? {} : { startLine },
52
+ ...endLine === void 0 ? {} : { endLine },
53
+ ...startColumn === void 0 ? {} : { startColumn },
54
+ ...endColumn === void 0 ? {} : { endColumn }
55
+ };
56
+ };
57
+ /** The inverse: annotation fields as a record `Effect.annotateLogs` accepts. */
58
+ const annotationRecord = (properties) => ({
59
+ ...properties.title === void 0 ? {} : { title: properties.title },
60
+ ...properties.file === void 0 ? {} : { file: properties.file },
61
+ ...properties.startLine === void 0 ? {} : { startLine: properties.startLine },
62
+ ...properties.endLine === void 0 ? {} : { endLine: properties.endLine },
63
+ ...properties.startColumn === void 0 ? {} : { startColumn: properties.startColumn },
64
+ ...properties.endColumn === void 0 ? {} : { endColumn: properties.endColumn }
65
+ });
66
+ /**
67
+ * Map one log entry onto the line the runner should see.
68
+ *
69
+ * @remarks
70
+ * `Info` is deliberately plain text with no command prefix — it is ordinary
71
+ * step output, and prefixing it would make every informational line an
72
+ * annotation in the workflow summary.
73
+ */
74
+ const renderEntry = (level, message, annotations) => {
75
+ const text = formatMessage(message);
76
+ if (LogLevel.isGreaterThanOrEqualTo(level, "Warn")) {
77
+ const properties = readAnnotations(annotations);
78
+ return LogLevel.isGreaterThanOrEqualTo(level, "Error") ? WorkflowCommand.error(text, properties) : WorkflowCommand.warning(text, properties);
79
+ }
80
+ return LogLevel.isGreaterThanOrEqualTo(level, "Info") ? text : WorkflowCommand.debug(text);
81
+ };
82
+ /**
83
+ * The buffer the current fiber is writing into, or `null` when not buffering.
84
+ *
85
+ * @remarks
86
+ * A `Context.Reference` rather than a module-level "current buffer": two steps
87
+ * running concurrently each keep their own transcript, and neither has to
88
+ * restore anything. A save/restore global is LIFO-correct only while the two
89
+ * buffers nest, which concurrent steps do not.
90
+ *
91
+ * @internal
92
+ */
93
+ const ActiveBuffer = Context.Reference("@effected/github-actions/ActiveBuffer", { defaultValue: () => null });
94
+ /** Write a transcript out and clear it, so a second flush is a no-op. */
95
+ const flush = (state) => Effect.suspend(() => {
96
+ if (state.entries.length === 0) return Effect.void;
97
+ const body = [
98
+ `--- Buffered output for "${state.label}" ---`,
99
+ ...state.entries,
100
+ `--- End buffered output for "${state.label}" ---`
101
+ ];
102
+ state.entries.length = 0;
103
+ return Effect.forEach(body, (line) => Console.log(line), { discard: true });
104
+ });
105
+ const make = Effect.gen(function* () {
106
+ const env = yield* ActionEnvironment;
107
+ const flushActive = Effect.flatMap(ActiveBuffer, (state) => state === null ? Effect.void : flush(state));
108
+ return {
109
+ group: (name, effect) => Effect.acquireUseRelease(Console.log(WorkflowCommand.group(name)), () => Effect.tapCause(effect, () => flushActive), () => Console.log(WorkflowCommand.endGroup())),
110
+ withBuffer: (label, effect) => Effect.gen(function* () {
111
+ const minimum = yield* References.MinimumLogLevel;
112
+ if ((yield* env.isDebug) || LogLevel.isLessThanOrEqualTo(minimum, "Debug")) return yield* effect;
113
+ const state = {
114
+ label,
115
+ entries: []
116
+ };
117
+ const buffering = Logger.make((options) => {
118
+ if (LogLevel.isGreaterThanOrEqualTo(options.logLevel, "Warn")) {
119
+ const annotations = options.fiber.getRef(References.CurrentLogAnnotations);
120
+ options.fiber.getRef(Console.Console).log(renderEntry(options.logLevel, options.message, annotations));
121
+ return;
122
+ }
123
+ state.entries.push(formatMessage(options.message));
124
+ });
125
+ return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit(() => flush(state)));
126
+ }),
127
+ notice: (message, properties = {}) => Console.log(WorkflowCommand.notice(message, properties)),
128
+ annotated: (properties, effect) => Effect.annotateLogs(effect, annotationRecord(properties))
129
+ };
130
+ });
131
+ /**
132
+ * Groups, buffered step transcripts, notices — and the `Logger` that renders
133
+ * every `Effect.log*` in the kit as a workflow command.
134
+ *
135
+ * @remarks
136
+ * The `Logger` is the seam that lets every other `@effected` package stay
137
+ * telemetry-agnostic: libraries call `Effect.logWarning`, and exactly one
138
+ * logger — installed at the edge by an action — turns those into `::warning::`
139
+ * annotations. No library needs to know it is running inside Actions.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * import { ActionLogger } from "@effected/github-actions";
144
+ * import { Effect } from "effect";
145
+ *
146
+ * const program = Effect.gen(function* () {
147
+ * const logger = yield* ActionLogger;
148
+ * yield* logger.group("install", logger.withBuffer("pnpm", Effect.logInfo("resolving")));
149
+ * });
150
+ * ```
151
+ *
152
+ * @public
153
+ */
154
+ var ActionLogger = class ActionLogger extends Context.Service()("@effected/github-actions/ActionLogger") {
155
+ static layer = Layer.effect(this, make);
156
+ /**
157
+ * The `Logger` that maps Effect log levels onto workflow commands.
158
+ *
159
+ * @remarks
160
+ * `Error` and `Fatal` render `::error::`, `Warn` renders `::warning::`,
161
+ * `Debug` and `Trace` render `::debug::`, and `Info` renders as plain text.
162
+ * Annotations set by `annotated` travel with the entry.
163
+ *
164
+ * It writes through core `Console`, which is what makes its output
165
+ * observable in a test without a runner.
166
+ */
167
+ static logger = Logger.make((options) => {
168
+ const annotations = options.fiber.getRef(References.CurrentLogAnnotations);
169
+ options.fiber.getRef(Console.Console).log(renderEntry(options.logLevel, options.message, annotations));
170
+ });
171
+ /** {@link ActionLogger.logger} installed as the only logger. */
172
+ static layerLogger = Logger.layer([ActionLogger.logger]);
173
+ /**
174
+ * A silent double.
175
+ *
176
+ * @remarks
177
+ * **A recorded exception to the die-on-unstubbed rule**, alongside
178
+ * {@link ActionEnvironment.makeTest}. A logger that dies when a suite logs
179
+ * would make every double unusable, and silence is the honest default for a
180
+ * service whose whole job is output. Group and buffer wrappers pass their
181
+ * effect through unchanged.
182
+ */
183
+ static makeTest = (overrides = {}) => ({
184
+ group: (_name, effect) => effect,
185
+ withBuffer: (_label, effect) => effect,
186
+ notice: () => Effect.void,
187
+ annotated: (_properties, effect) => effect,
188
+ ...overrides
189
+ });
190
+ /** {@link ActionLogger.makeTest} behind `Layer.succeed`. */
191
+ static layerTest = (overrides = {}) => Layer.succeed(ActionLogger, ActionLogger.makeTest(overrides));
192
+ /**
193
+ * A silent service **and** a silent `Effect.log*`.
194
+ *
195
+ * @remarks
196
+ * This one layer replaces the bare `Effect.provide(Logger.layer([]))` that
197
+ * appeared thirteen times in one consuming action and twenty-three in
198
+ * another, purely to keep test output quiet. Bound to a constant rather than
199
+ * exposed as a factory, so composing it twice is free.
200
+ */
201
+ static layerSilent = Layer.mergeAll(ActionLogger.layerTest(), Logger.layer([]));
202
+ };
203
+
204
+ //#endregion
205
+ export { ActionLogger };
@@ -0,0 +1,135 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { WorkflowCommand } from "./WorkflowCommand.js";
3
+ import { Console, Context, Effect, FileSystem, Layer, Schema } from "effect";
4
+
5
+ //#region src/ActionOutputs.ts
6
+ /**
7
+ * Raised when an action cannot publish an output.
8
+ *
9
+ * @public
10
+ */
11
+ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputError", {
12
+ /**
13
+ * `unavailable` — the runner file variable is not set, which usually means
14
+ * the code is running outside a GitHub runner. `writeFailed` — the file
15
+ * exists but could not be appended to. `invalidName` — the name would
16
+ * corrupt the file's block structure. `encodeFailed` — a value did not
17
+ * satisfy its schema.
18
+ */
19
+ reason: Schema.Literals([
20
+ "unavailable",
21
+ "writeFailed",
22
+ "invalidName",
23
+ "encodeFailed"
24
+ ]),
25
+ /** Which runner file was involved, by environment variable name. */
26
+ file: Schema.optionalKey(Schema.String),
27
+ /** The output or variable name, when one is involved. */
28
+ name: Schema.optionalKey(Schema.String),
29
+ /** The underlying failure, preserved structurally. */
30
+ cause: Schema.optionalKey(Schema.Defect())
31
+ }) {
32
+ get message() {
33
+ switch (this.reason) {
34
+ case "unavailable": return `Runner file "${this.file}" is not available; is this running on a GitHub runner?`;
35
+ case "writeFailed": return `Failed to write to runner file "${this.file}"`;
36
+ case "invalidName": return `"${this.name}" is not a usable output name`;
37
+ default: return `Failed to encode the value for "${this.name}"`;
38
+ }
39
+ }
40
+ };
41
+ /** The base delimiter for a heredoc block. */
42
+ const BASE_DELIMITER = "EFFECTED_EOF";
43
+ /**
44
+ * A delimiter guaranteed absent from `value`.
45
+ *
46
+ * @remarks
47
+ * GitHub's own toolkit uses a random UUID here and accepts the (tiny) chance
48
+ * of collision. Deriving the delimiter instead makes collision **impossible**
49
+ * rather than improbable, needs no randomness — so no `Crypto` in `R` and no
50
+ * unreproducible output — and is trivially testable. A value that contains a
51
+ * delimiter would terminate its block early and corrupt every entry after it,
52
+ * which is a value-controlled injection into the runner's own file.
53
+ */
54
+ const delimiterFor = (value) => {
55
+ let delimiter = BASE_DELIMITER;
56
+ while (value.includes(delimiter)) delimiter = `${delimiter}_`;
57
+ return delimiter;
58
+ };
59
+ /** A name that would break the block structure it lives in. */
60
+ const isUsableName = (name) => name !== "" && !/[\r\n]/.test(name);
61
+ const make = Effect.gen(function* () {
62
+ const env = yield* ActionEnvironment;
63
+ const fs = yield* FileSystem.FileSystem;
64
+ const append = (file, content) => Effect.gen(function* () {
65
+ const path = yield* env.get(file).pipe(Effect.mapError(() => new ActionOutputError({
66
+ reason: "unavailable",
67
+ file
68
+ })));
69
+ yield* fs.writeFileString(path, content, { flag: "a" }).pipe(Effect.mapError((cause) => new ActionOutputError({
70
+ reason: "writeFailed",
71
+ file,
72
+ cause
73
+ })));
74
+ });
75
+ const appendBlock = (file, name, value) => {
76
+ if (!isUsableName(name)) return Effect.fail(new ActionOutputError({
77
+ reason: "invalidName",
78
+ file,
79
+ name
80
+ }));
81
+ const delimiter = delimiterFor(value);
82
+ return append(file, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
83
+ };
84
+ const set = (name, value) => appendBlock("GITHUB_OUTPUT", name, value);
85
+ return {
86
+ set,
87
+ setJson: (name, value, schema) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new ActionOutputError({
88
+ reason: "encodeFailed",
89
+ name,
90
+ cause
91
+ })), Effect.flatMap((encoded) => set(name, JSON.stringify(encoded)))),
92
+ summary: (content) => append("GITHUB_STEP_SUMMARY", content),
93
+ exportVariable: (name, value) => appendBlock("GITHUB_ENV", name, value),
94
+ addPath: (path) => append("GITHUB_PATH", `${path}\n`),
95
+ setFailed: (message) => Console.log(WorkflowCommand.error(message)),
96
+ setSecret: (value) => Console.log(WorkflowCommand.addMask(value))
97
+ };
98
+ });
99
+ const unimplemented = (member) => {
100
+ throw new Error(`ActionOutputs.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
101
+ };
102
+ /**
103
+ * Everything an action publishes: step outputs, exported variables, `PATH`
104
+ * additions, the job summary, log masking and failure annotations.
105
+ *
106
+ * @remarks
107
+ * Outputs and variables go to the runner's **files** (`GITHUB_OUTPUT` and
108
+ * friends); masking and annotations go to stdout as workflow commands, through
109
+ * core `Console` so a test can observe them without a runner.
110
+ *
111
+ * `setFailed` emits the annotation but does **not** set the process exit code
112
+ * — that is `Action.run`'s job, so that an action which reports a failure and
113
+ * then recovers is not silently doomed by a side effect it cannot undo.
114
+ *
115
+ * @public
116
+ */
117
+ var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/github-actions/ActionOutputs") {
118
+ static layer = Layer.effect(this, make);
119
+ /** A test double. Unstubbed members die rather than silently succeeding. */
120
+ static makeTest = (overrides = {}) => ({
121
+ set: () => Effect.sync(() => unimplemented("set")),
122
+ setJson: () => Effect.sync(() => unimplemented("setJson")),
123
+ summary: () => Effect.sync(() => unimplemented("summary")),
124
+ exportVariable: () => Effect.sync(() => unimplemented("exportVariable")),
125
+ addPath: () => Effect.sync(() => unimplemented("addPath")),
126
+ setFailed: () => Effect.sync(() => unimplemented("setFailed")),
127
+ setSecret: () => Effect.sync(() => unimplemented("setSecret")),
128
+ ...overrides
129
+ });
130
+ /** {@link ActionOutputs.makeTest} behind `Layer.succeed`. */
131
+ static layerTest = (overrides = {}) => Layer.succeed(ActionOutputs, ActionOutputs.makeTest(overrides));
132
+ };
133
+
134
+ //#endregion
135
+ export { ActionOutputError, ActionOutputs };
package/ActionState.js ADDED
@@ -0,0 +1,117 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { ActionOutputs } from "./ActionOutputs.js";
3
+ import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
4
+
5
+ //#region src/ActionState.ts
6
+ /**
7
+ * Raised when state cannot cross the phase boundary.
8
+ *
9
+ * @public
10
+ */
11
+ var ActionStateError = class extends Schema.TaggedErrorClass()("ActionStateError", {
12
+ /**
13
+ * `missing` — no value was saved under this key in an earlier phase.
14
+ * `malformed` — a value is there but is not JSON, or does not satisfy the
15
+ * schema it was read with. `writeFailed` — the state file could not be
16
+ * appended to.
17
+ */
18
+ reason: Schema.Literals([
19
+ "missing",
20
+ "malformed",
21
+ "writeFailed"
22
+ ]),
23
+ key: Schema.String,
24
+ cause: Schema.optionalKey(Schema.Defect())
25
+ }) {
26
+ get message() {
27
+ switch (this.reason) {
28
+ case "missing": return `No action state was saved under "${this.key}"`;
29
+ case "malformed": return `Action state under "${this.key}" could not be decoded`;
30
+ default: return `Failed to persist action state under "${this.key}"`;
31
+ }
32
+ }
33
+ };
34
+ /** The runner republishes saved state as `STATE_<key>`. */
35
+ const stateVariable = (key) => `STATE_${key}`;
36
+ const make = Effect.gen(function* () {
37
+ const env = yield* ActionEnvironment;
38
+ const fs = yield* FileSystem.FileSystem;
39
+ const outputs = yield* ActionOutputs;
40
+ const write = (key, serialized) => Effect.gen(function* () {
41
+ const path = yield* env.get("GITHUB_STATE").pipe(Effect.mapError((cause) => new ActionStateError({
42
+ reason: "writeFailed",
43
+ key,
44
+ cause
45
+ })));
46
+ let delimiter = "EFFECTED_EOF";
47
+ while (serialized.includes(delimiter)) delimiter = `${delimiter}_`;
48
+ yield* fs.writeFileString(path, `${key}<<${delimiter}\n${serialized}\n${delimiter}\n`, { flag: "a" }).pipe(Effect.mapError((cause) => new ActionStateError({
49
+ reason: "writeFailed",
50
+ key,
51
+ cause
52
+ })));
53
+ });
54
+ const read = (key, schema) => Effect.gen(function* () {
55
+ const raw = yield* env.getOptional(stateVariable(key));
56
+ if (Option.isNone(raw)) return Option.none();
57
+ const parsed = yield* Effect.try({
58
+ try: () => JSON.parse(raw.value),
59
+ catch: (cause) => new ActionStateError({
60
+ reason: "malformed",
61
+ key,
62
+ cause
63
+ })
64
+ });
65
+ const decoded = yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError((cause) => new ActionStateError({
66
+ reason: "malformed",
67
+ key,
68
+ cause
69
+ })));
70
+ return Option.some(decoded);
71
+ });
72
+ const save = (key, value, schema) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new ActionStateError({
73
+ reason: "malformed",
74
+ key,
75
+ cause
76
+ })), Effect.flatMap((encoded) => write(key, JSON.stringify(encoded))));
77
+ return {
78
+ save,
79
+ getOptional: read,
80
+ get: (key, schema) => Effect.flatMap(read(key, schema), (found) => Option.isSome(found) ? Effect.succeed(found.value) : Effect.fail(new ActionStateError({
81
+ reason: "missing",
82
+ key
83
+ }))),
84
+ saveSecret: (key, secret) => Effect.flatMap(outputs.setSecret(secret), () => write(key, JSON.stringify(secret)))
85
+ };
86
+ });
87
+ const unimplemented = (member) => {
88
+ throw new Error(`ActionState.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
89
+ };
90
+ /**
91
+ * State that survives the `pre` → `main` → `post` phase boundary.
92
+ *
93
+ * @remarks
94
+ * Each phase of an action is a **separate process**. GitHub's protocol is a
95
+ * write-only file (`GITHUB_STATE`) whose entries the runner republishes to the
96
+ * next phase as `STATE_<key>` environment variables — so saving and reading go
97
+ * through different mechanisms, which is why this is a service rather than a
98
+ * pair of helpers.
99
+ *
100
+ * @public
101
+ */
102
+ var ActionState = class ActionState extends Context.Service()("@effected/github-actions/ActionState") {
103
+ static layer = Layer.effect(this, make);
104
+ /** A test double. Unstubbed members die rather than answering wrongly. */
105
+ static makeTest = (overrides = {}) => ({
106
+ save: () => Effect.sync(() => unimplemented("save")),
107
+ get: () => Effect.sync(() => unimplemented("get")),
108
+ getOptional: () => Effect.sync(() => unimplemented("getOptional")),
109
+ saveSecret: () => Effect.sync(() => unimplemented("saveSecret")),
110
+ ...overrides
111
+ });
112
+ /** {@link ActionState.makeTest} behind `Layer.succeed`. */
113
+ static layerTest = (overrides = {}) => Layer.succeed(ActionState, ActionState.makeTest(overrides));
114
+ };
115
+
116
+ //#endregion
117
+ export { ActionState, ActionStateError };