@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.
- package/Action.js +138 -0
- package/ActionCache.js +317 -0
- package/ActionEnvironment.js +255 -0
- package/ActionInput.js +346 -0
- package/ActionLogger.js +205 -0
- package/ActionOutputs.js +135 -0
- package/ActionState.js +117 -0
- package/Artifact.js +423 -0
- package/BlobEnvelope.js +135 -0
- package/BlobStore.githubCache.js +183 -0
- package/BlobStore.js +203 -0
- package/BlobTransfer.js +21 -0
- package/CacheKey.js +234 -0
- package/DetachedProcess.js +231 -0
- package/DryRun.js +73 -0
- package/GitHubToken.js +226 -0
- package/LICENSE +21 -0
- package/OidcTokenIssuer.js +246 -0
- package/Secret.js +106 -0
- package/ToolInstaller.js +293 -0
- package/WorkflowCommand.js +100 -0
- package/index.d.ts +2467 -0
- package/index.js +22 -0
- package/internal/actionsResults.js +98 -0
- package/internal/sigv4.js +117 -0
- package/internal/twirp.js +97 -0
- package/package.json +52 -0
- package/tsdoc-metadata.json +11 -0
package/ActionLogger.js
ADDED
|
@@ -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 };
|
package/ActionOutputs.js
ADDED
|
@@ -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 };
|