@factorialco/gat 3.2.1 → 3.3.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/job.d.ts CHANGED
@@ -20,7 +20,7 @@ export interface Service {
20
20
  options?: string;
21
21
  volumes?: string[];
22
22
  }
23
- export interface BaseJobOptions<RunnerDefinition, Name> {
23
+ export interface StepsJobOptions<Step, RunnerDefinition, Name> {
24
24
  prettyName?: string;
25
25
  permissions?: object;
26
26
  ifExpression?: string;
@@ -33,11 +33,12 @@ export interface BaseJobOptions<RunnerDefinition, Name> {
33
33
  matrix?: Matrix | string;
34
34
  outputs?: Record<string, string>;
35
35
  workingDirectory?: string;
36
- }
37
- export interface StepsJobOptions<Step, RunnerDefinition, Name> extends BaseJobOptions<RunnerDefinition, Name> {
38
36
  steps: Step[];
39
37
  }
40
- export interface UsesJobOptions<RunnerDefinition, Name> extends BaseJobOptions<RunnerDefinition, Name> {
38
+ export interface UsesJobOptions<Name> {
39
+ prettyName?: string;
40
+ dependsOn?: Array<Name>;
41
+ ifExpression?: string;
41
42
  uses: string;
42
43
  with?: Record<string, string | number | boolean | object>;
43
44
  secrets?: Record<string, string | number | boolean | object> | "inherit";
@@ -45,5 +46,5 @@ export interface UsesJobOptions<RunnerDefinition, Name> extends BaseJobOptions<R
45
46
  export type StringWithNoSpaces<T> = T extends `${string} ${string}` ? never : T extends ` ${string}` ? never : T extends `${string} ` ? never : T;
46
47
  export interface Job<Step, RunnerDefinition, Name> {
47
48
  name: string;
48
- options: StepsJobOptions<Step, RunnerDefinition, Name> | UsesJobOptions<RunnerDefinition, Name>;
49
+ options: StepsJobOptions<Step, RunnerDefinition, Name> | UsesJobOptions<Name>;
49
50
  }
@@ -8,6 +8,11 @@ interface EnvVar {
8
8
  name: string;
9
9
  value: string;
10
10
  }
11
+ interface CompileOptions {
12
+ filePath?: string;
13
+ lockFilePath?: string;
14
+ writeLockFile?: boolean;
15
+ }
11
16
  export type RunnerDefinition = string | {
12
17
  group: string;
13
18
  labels?: string[];
@@ -22,10 +27,10 @@ export declare class Workflow<JobStep extends Step = Step, Runner extends Runner
22
27
  constructor(name: string);
23
28
  on<T extends EventName>(name: T, options?: EventOptions<T>): this;
24
29
  addDefaults(options: DefaultOptions): this;
25
- addJob<T extends string>(name: StringWithNoSpaces<T>, options: StepsJobOptions<JobStep, Runner, JobName> | UsesJobOptions<Runner, JobName>): Workflow<JobStep, Runner, JobName | T>;
30
+ addJob<T extends string>(name: StringWithNoSpaces<T>, options: StepsJobOptions<JobStep, Runner, JobName> | UsesJobOptions<JobName>): Workflow<JobStep, Runner, JobName | T>;
26
31
  setEnv(name: string, value: string): this;
27
32
  setConcurrencyGroup(concurrencyGroup: ConcurrencyGroup | null): this;
28
33
  defaultRunner(): string;
29
- compile(filepath?: string): Promise<string | void>;
34
+ compile(compileOptions?: CompileOptions): Promise<string | void>;
30
35
  }
31
36
  export {};
package/dist/workflow.js CHANGED
@@ -12,28 +12,38 @@ const util_1 = require("util");
12
12
  const axios_1 = __importDefault(require("axios"));
13
13
  const step_1 = require("./step");
14
14
  const writeFilePromise = (0, util_1.promisify)(fs_1.default.writeFile);
15
- const chainAttackCache = {};
16
- const supplyChainAttack = async (step, enabled = false) => {
15
+ const supplyChainAttack = async (step, compileOptions) => {
16
+ const { lockFilePath, writeLockFile = false } = compileOptions;
17
17
  if (!(0, step_1.isUseStep)(step))
18
18
  return;
19
- if (!enabled)
19
+ // The user is not interested in frozen sha versions
20
+ if (!lockFilePath)
20
21
  return step.uses;
21
22
  const uses = step.uses;
22
- if (!uses)
23
- return uses;
24
- if (chainAttackCache[uses])
25
- return chainAttackCache[uses];
26
23
  const match = uses.match(/(?<repository>.*)@(?<version>.*)/);
24
+ // The uses is not a valid Github action with a tag
27
25
  if (!match)
28
26
  return uses;
29
27
  const { repository, version } = match.groups;
28
+ const chainAttackCache = fs_1.default.existsSync(lockFilePath)
29
+ ? JSON.parse(fs_1.default.readFileSync(lockFilePath, "utf8"))
30
+ : {};
31
+ if (chainAttackCache[uses])
32
+ return chainAttackCache[uses];
33
+ if (!writeLockFile) {
34
+ throw new Error(`Unable to retrieve ${uses} from lock file and writeLockFile is false`);
35
+ }
30
36
  const response = await axios_1.default.get(`https://api.github.com/repos/${repository}/tags`);
31
37
  const tags = response.data;
32
38
  const tag = tags.find((tag) => tag.name === version);
33
- if (!tag)
34
- return uses;
39
+ if (!tag) {
40
+ throw new Error(`Unable to retrieve ${uses} from Github tags`);
41
+ }
35
42
  const result = `${repository}@${tag.commit.sha}`;
36
43
  chainAttackCache[uses] = result;
44
+ if (writeLockFile) {
45
+ fs_1.default.writeFileSync(lockFilePath, JSON.stringify(chainAttackCache, null, 2));
46
+ }
37
47
  return result;
38
48
  };
39
49
  class Workflow {
@@ -67,7 +77,7 @@ class Workflow {
67
77
  defaultRunner() {
68
78
  return "ubuntu-22.04";
69
79
  }
70
- async compile(filepath) {
80
+ async compile(compileOptions = {}) {
71
81
  const result = {
72
82
  name: this.name,
73
83
  on: Object.fromEntries(this.events.map(({ name, options }) => [
@@ -91,60 +101,59 @@ class Workflow {
91
101
  ? Object.fromEntries(this.env.map(({ name, value }) => [name, value]))
92
102
  : undefined,
93
103
  jobs: Object.fromEntries(await Promise.all(this.jobs.map(async ({ name, options: jobOptions }) => {
94
- const { prettyName, permissions, ifExpression, runsOn, matrix, env, dependsOn, services, timeout, concurrency, outputs, workingDirectory, } = jobOptions;
95
- const computedOptions = {
96
- name: prettyName,
97
- permissions,
98
- if: ifExpression,
99
- "runs-on": runsOn ?? this.defaultRunner(),
100
- "timeout-minutes": timeout ?? 15,
101
- needs: dependsOn,
102
- services,
103
- concurrency: concurrency
104
- ? {
105
- group: `${(0, kebabCase_1.default)(this.name)}-${name}-${concurrency.groupSuffix}`,
106
- "cancel-in-progress": concurrency.cancelPrevious,
107
- }
108
- : undefined,
109
- strategy: matrix
110
- ? {
111
- "fail-fast": false,
112
- matrix: typeof matrix === "string"
113
- ? matrix
114
- : {
115
- ...Object.fromEntries(matrix.elements.map(({ id, options }) => [
116
- id,
117
- options,
118
- ])),
119
- include: matrix.extra,
120
- },
121
- }
122
- : undefined,
123
- env,
124
- defaults: workingDirectory
125
- ? {
126
- run: {
127
- "working-directory": workingDirectory,
128
- },
129
- }
130
- : undefined,
131
- };
132
104
  if ("uses" in jobOptions) {
105
+ const { prettyName, ifExpression, dependsOn } = jobOptions;
133
106
  return [
134
107
  name,
135
108
  {
136
- ...computedOptions,
109
+ name: prettyName,
110
+ if: ifExpression,
111
+ needs: dependsOn,
137
112
  uses: jobOptions.uses,
138
113
  with: jobOptions.with,
139
114
  secrets: jobOptions.secrets,
140
- outputs,
141
115
  },
142
116
  ];
143
117
  }
118
+ const { prettyName, permissions, ifExpression, runsOn, matrix, env, dependsOn, services, timeout, concurrency, outputs, workingDirectory, } = jobOptions;
144
119
  return [
145
120
  name,
146
121
  {
147
- ...computedOptions,
122
+ name: prettyName,
123
+ permissions,
124
+ if: ifExpression,
125
+ "runs-on": runsOn ?? this.defaultRunner(),
126
+ "timeout-minutes": timeout ?? 15,
127
+ needs: dependsOn,
128
+ services,
129
+ concurrency: concurrency
130
+ ? {
131
+ group: `${(0, kebabCase_1.default)(this.name)}-${name}-${concurrency.groupSuffix}`,
132
+ "cancel-in-progress": concurrency.cancelPrevious,
133
+ }
134
+ : undefined,
135
+ strategy: matrix
136
+ ? {
137
+ "fail-fast": false,
138
+ matrix: typeof matrix === "string"
139
+ ? matrix
140
+ : {
141
+ ...Object.fromEntries(matrix.elements.map(({ id, options }) => [
142
+ id,
143
+ options,
144
+ ])),
145
+ include: matrix.extra,
146
+ },
147
+ }
148
+ : undefined,
149
+ env,
150
+ defaults: workingDirectory
151
+ ? {
152
+ run: {
153
+ "working-directory": workingDirectory,
154
+ },
155
+ }
156
+ : undefined,
148
157
  steps: await Promise.all(jobOptions.steps.map(async (step) => {
149
158
  const { id, name, ifExpression, workingDirectory, continueOnError, timeout, ...options } = step;
150
159
  return {
@@ -155,7 +164,7 @@ class Workflow {
155
164
  "working-directory": workingDirectory,
156
165
  "timeout-minutes": timeout,
157
166
  ...options,
158
- uses: await supplyChainAttack(step),
167
+ uses: await supplyChainAttack(step, compileOptions),
159
168
  };
160
169
  })),
161
170
  outputs,
@@ -168,9 +177,9 @@ class Workflow {
168
177
  lineWidth: 200,
169
178
  noCompatMode: true,
170
179
  })}`;
171
- if (!filepath)
180
+ if (!compileOptions.filePath)
172
181
  return compiled;
173
- return writeFilePromise(path_1.default.join(process.cwd(), ".github", "workflows", filepath), compiled);
182
+ return writeFilePromise(path_1.default.join(process.cwd(), ".github", "workflows", compileOptions.filePath), compiled);
174
183
  }
175
184
  }
176
185
  exports.Workflow = Workflow;
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  const vitest_1 = require("vitest");
4
7
  const workflow_1 = require("./workflow");
8
+ const fs_1 = __importDefault(require("fs"));
5
9
  (0, vitest_1.describe)("Workflow", () => {
6
10
  (0, vitest_1.it)("generates a simple workflow", async () => {
7
11
  const workflow = new workflow_1.Workflow("Simple");
@@ -278,4 +282,31 @@ exit 0`,
278
282
  });
279
283
  (0, vitest_1.expect)(await workflow.compile()).toMatchSnapshot();
280
284
  });
285
+ (0, vitest_1.it)("supports supply chain attack, without writing the lock file", async () => {
286
+ const workflow = new workflow_1.Workflow("Supply chain attack");
287
+ workflow.on("push").addJob("job1", {
288
+ steps: [
289
+ { name: "Do something", uses: "tj-actions/changed-files@v45.0.7" },
290
+ ],
291
+ });
292
+ fs_1.default.writeFileSync("/tmp/lockfile1.json", JSON.stringify({
293
+ "tj-actions/changed-files@v45.0.7": "tj-actions/changed-files@youhavebeenhacked",
294
+ }, null, 2));
295
+ (0, vitest_1.expect)(await workflow.compile({
296
+ lockFilePath: "/tmp/lockfile1.json",
297
+ writeLockFile: false,
298
+ })).toMatchSnapshot();
299
+ });
300
+ (0, vitest_1.it)("supports supply chain attack, with writing the lock file", async () => {
301
+ const workflow = new workflow_1.Workflow("Supply chain attack");
302
+ workflow.on("push").addJob("job1", {
303
+ steps: [
304
+ { name: "Do something", uses: "tj-actions/changed-files@v45.0.7" },
305
+ ],
306
+ });
307
+ (0, vitest_1.expect)(await workflow.compile({
308
+ lockFilePath: "/tmp/lockfile2.json",
309
+ writeLockFile: true,
310
+ })).toMatchSnapshot();
311
+ });
281
312
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factorialco/gat",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "Write your GitHub Actions workflows using TypeScript",
5
5
  "bin": {
6
6
  "gat": "dist/cli.js"
@@ -33,7 +33,7 @@
33
33
  "eslint": "^8.23.0",
34
34
  "eslint-config-prettier": "^9.0.0",
35
35
  "prettier": "^3.0.3",
36
- "tsx": "^3.14.0",
36
+ "tsx": "^4.19.3",
37
37
  "typescript": "^5.2.2",
38
38
  "vitest": "^0.34.6"
39
39
  },