diff --git a/README.md b/README.md index e6c7962..fddace4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ new Workflow("My first workflow") }, ], }) - .compile('my-first-workflow.yml'); + .compile("my-first-workflow.yml"); ``` Notice that you need to call the `compile()` method at the end, passing the file name of the generated Github Actions workflow. @@ -52,12 +52,6 @@ You can build your templates running this command in your root folder: npx gat build ``` -Alternatively you can also compile a single template: - -```bash -npx gat build .github/templates/some-workflow.ts -``` - Following the previous example, you should see now a file `.github/workflows/my-first-workflow.yml` like this: ```yaml diff --git a/src/__snapshots__/workflow.spec.ts.snap b/src/__snapshots__/workflow.spec.ts.snap index 93bcba6..726944b 100644 --- a/src/__snapshots__/workflow.spec.ts.snap +++ b/src/__snapshots__/workflow.spec.ts.snap @@ -204,7 +204,7 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 with: ref: main " diff --git a/src/cli.ts b/src/cli.ts index b8fd7cf..949e50f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,8 +25,8 @@ cli await execPromise( `npx ts-node ${process.env["GAT_BUILD_FLAGS"] ?? "--swc -T"} ${path.join( folder, - "index.ts" - )}` + "index.ts", + )}`, ); process.exit(0); diff --git a/src/step.ts b/src/step.ts index 608a2c5..6c728b6 100644 --- a/src/step.ts +++ b/src/step.ts @@ -18,3 +18,7 @@ export interface UseStep extends BaseStep { uses: string; with?: Record; } + +export const isUseStep = (step: Step): step is UseStep => { + return (step as UseStep).uses !== undefined; +}; diff --git a/src/workflow.spec.ts b/src/workflow.spec.ts index a942666..b90d865 100644 --- a/src/workflow.spec.ts +++ b/src/workflow.spec.ts @@ -3,7 +3,7 @@ import { RunStep, UseStep } from "./step"; import { Workflow } from "./workflow"; describe("Workflow", () => { - it("generates a simple workflow", () => { + it("generates a simple workflow", async () => { const workflow = new Workflow("Simple"); workflow .on("pull_request", { types: ["opened"] }) @@ -15,10 +15,10 @@ describe("Workflow", () => { dependsOn: ["job1"], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows multiple events", () => { + it("allows multiple events", async () => { const workflow = new Workflow("Multiple events"); workflow .on("push", { branches: ["main"] }) @@ -26,10 +26,10 @@ describe("Workflow", () => { .addJob("job1", { steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows declaring default options", () => { + it("allows declaring default options", async () => { const workflow = new Workflow("Default options"); workflow .on("push", { branches: ["main"] }) @@ -39,10 +39,10 @@ describe("Workflow", () => { .addJob("job1", { steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows declaring environment variables", () => { + it("allows declaring environment variables", async () => { const workflow = new Workflow("With Environment variables"); workflow .on("push") @@ -56,10 +56,10 @@ describe("Workflow", () => { }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows using a concurrency group", () => { + it("allows using a concurrency group", async () => { const workflow = new Workflow("Concurrency group"); workflow.on("push").addJob("job1", { concurrency: { @@ -72,10 +72,10 @@ describe("Workflow", () => { }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows using outputs", () => { + it("allows using outputs", async () => { const workflow = new Workflow("Using outputs"); workflow.on("push").addJob("job1", { steps: [ @@ -88,10 +88,10 @@ describe("Workflow", () => { "random-number": "${{ steps.random-number.outputs.random-number }}", }, }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows conditional jobs", () => { + it("allows conditional jobs", async () => { const workflow = new Workflow("Conditional job"); workflow.on("push").addJob("job1", { ifExpression: "${{ github.ref != 'refs/heads/main' }}", @@ -101,10 +101,10 @@ describe("Workflow", () => { }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows a job matrix", () => { + it("allows a job matrix", async () => { const workflow = new Workflow("Conditional job"); workflow.on("push").addJob("job1", { matrix: { @@ -132,10 +132,10 @@ describe("Workflow", () => { }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows uses steps", () => { + it("allows uses steps", async () => { const workflow = new Workflow("Uses steps"); workflow .on("push") @@ -151,10 +151,10 @@ describe("Workflow", () => { }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows custom types in a workflow", () => { + it("allows custom types in a workflow", async () => { interface MyUseStep extends UseStep { uses: "custom-action"; with: { foo: string }; @@ -163,7 +163,7 @@ describe("Workflow", () => { type CustomRunner = "standard-runner"; const workflow = new Workflow( - "With custom types" + "With custom types", ); workflow.on("push").addJob("job1", { @@ -181,10 +181,10 @@ describe("Workflow", () => { ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("support workflow dispatch event", () => { + it("support workflow dispatch event", async () => { const workflow = new Workflow("Workflow dispatch"); workflow .on("workflow_dispatch", { @@ -203,29 +203,29 @@ describe("Workflow", () => { .addJob("job1", { steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("supports schedule event", () => { + it("supports schedule event", async () => { const workflow = new Workflow("Schedule") .on("schedule", [{ cron: "0 4 * * 1-5" }]) .addJob("job1", { steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("supports a pretty name for the job", () => { + it("supports a pretty name for the job", async () => { const workflow = new Workflow("Job with pretty name") .on("push") .addJob("job1", { prettyName: "My pretty name", steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows permissions into jobs", () => { + it("allows permissions into jobs", async () => { const workflow = new Workflow("Job with permissions") .on("push") .addJob("job1", { @@ -235,10 +235,10 @@ describe("Workflow", () => { }, steps: [{ name: "Do something", run: "exit 0" }], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows multiline strings", () => { + it("allows multiline strings", async () => { const workflow = new Workflow("Multiline strings") .on("push") .addJob("job1", { @@ -250,10 +250,10 @@ exit 0`, }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); - it("allows concurrency groups at workflow level", () => { + it("allows concurrency groups at workflow level", async () => { const workflow = new Workflow("Concurrency at workflow level") .on("push") .setConcurrencyGroup({ @@ -268,6 +268,6 @@ exit 0`, }, ], }); - expect(workflow.compile()).toMatchSnapshot(); + expect(await workflow.compile()).toMatchSnapshot(); }); }); diff --git a/src/workflow.ts b/src/workflow.ts index 5b8212f..38cafaf 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -6,7 +6,7 @@ import { promisify } from "util"; import { ConcurrencyGroup, Job, JobOptions, StringWithNoSpaces } from "./job"; import type { Event, EventName, EventOptions } from "./event"; -import { BaseStep, Step } from "./step"; +import { Step, isUseStep } from "./step"; const writeFilePromise = promisify(fs.writeFile); @@ -21,10 +21,53 @@ interface EnvVar { value: string; } +interface Tag { + name: string; + commit: { + sha: string; + }; +} + +const chainAttackCache: Record = {}; + +const supplyChainAttack = async (step: Step) => { + if (!isUseStep(step)) return; + + const uses = step.uses; + + if (!uses) return uses; + + if (chainAttackCache[uses]) return chainAttackCache[uses]; + + const match = uses.match(/(?.*)@(?.*)/); + + if (!match) return uses; + + const { repository, version } = match.groups as { + repository: string; + version: string; + }; + + const response = await fetch( + `https://api.github.com/repos/${repository}/tags`, + ); + const tags = (await response.json()) as Tag[]; + + const tag = tags.find((tag) => tag.name === version); + + if (!tag) return uses; + + const result = `${repository}@${tag.commit.sha}`; + + chainAttackCache[uses] = result; + + return result; +}; + export class Workflow< - JobStep extends BaseStep = Step, + JobStep extends Step = Step, Runner = typeof DEFAULT_RUNNERS, - JobName = never + JobName = never, > { events: Event[]; jobs: Array>; @@ -51,7 +94,7 @@ export class Workflow< addJob( name: StringWithNoSpaces, - options: JobOptions + options: JobOptions, ): Workflow { this.jobs = [...this.jobs, { name, options }]; return this; @@ -78,11 +121,14 @@ export class Workflow< return isSelfHosted ? ["self-hosted", runnerName] : runnerName; } - compile(filepath?: string) { + async compile(filepath?: string) { const result = { name: this.name, on: Object.fromEntries( - this.events.map(({ name, options }) => [name, options ? options : null]) + this.events.map(({ name, options }) => [ + name, + options ? options : null, + ]), ), concurrency: this.concurrencyGroup ? { @@ -102,90 +148,96 @@ export class Workflow< ? Object.fromEntries(this.env.map(({ name, value }) => [name, value])) : undefined, jobs: Object.fromEntries( - this.jobs.map( - ({ - name, - options: { - prettyName, - permissions, - ifExpression, - runsOn, - matrix, - env, - steps, - dependsOn, - services, - timeout, - concurrency, - outputs, - workingDirectory, - }, - }) => [ - name, - { - name: prettyName, - permissions, - if: ifExpression, - "runs-on": this.assignRunner(runsOn), - "timeout-minutes": timeout ?? 15, - needs: dependsOn, - services, - concurrency: concurrency - ? { - group: `${kebabCase(this.name)}-${name}-${ - concurrency.groupSuffix - }`, - "cancel-in-progress": concurrency.cancelPrevious, - } - : undefined, - strategy: matrix - ? { - "fail-fast": false, - matrix: - typeof matrix === "string" - ? matrix - : { - ...Object.fromEntries( - matrix.elements.map(({ id, options }) => [ - id, - options, - ]) - ), - include: matrix.extra, - }, - } - : undefined, - env, - defaults: workingDirectory - ? { - run: { + await Promise.all( + this.jobs.map( + async ({ + name, + options: { + prettyName, + permissions, + ifExpression, + runsOn, + matrix, + env, + steps, + dependsOn, + services, + timeout, + concurrency, + outputs, + workingDirectory, + }, + }) => [ + name, + { + name: prettyName, + permissions, + if: ifExpression, + "runs-on": this.assignRunner(runsOn), + "timeout-minutes": timeout ?? 15, + needs: dependsOn, + services, + concurrency: concurrency + ? { + group: `${kebabCase(this.name)}-${name}-${ + concurrency.groupSuffix + }`, + "cancel-in-progress": concurrency.cancelPrevious, + } + : undefined, + strategy: matrix + ? { + "fail-fast": false, + matrix: + typeof matrix === "string" + ? matrix + : { + ...Object.fromEntries( + matrix.elements.map(({ id, options }) => [ + id, + options, + ]), + ), + include: matrix.extra, + }, + } + : undefined, + env, + defaults: workingDirectory + ? { + run: { + "working-directory": workingDirectory, + }, + } + : undefined, + steps: await Promise.all( + steps.map(async (step) => { + const { + id, + name, + ifExpression, + workingDirectory, + continueOnError, + timeout, + ...options + } = step; + return { + id, + name, + if: ifExpression, + "continue-on-error": continueOnError, "working-directory": workingDirectory, - }, - } - : undefined, - steps: steps.map( - ({ - id, - name, - ifExpression, - workingDirectory, - continueOnError, - timeout, - ...options - }) => ({ - id, - name, - if: ifExpression, - "continue-on-error": continueOnError, - "working-directory": workingDirectory, - "timeout-minutes": timeout, - ...options, - }) - ), - outputs, - }, - ] - ) + "timeout-minutes": timeout, + ...options, + uses: await supplyChainAttack(step), + }; + }), + ), + outputs, + }, + ], + ), + ), ), }; @@ -195,14 +247,14 @@ export class Workflow< noRefs: true, lineWidth: 200, noCompatMode: true, - } + }, )}`; if (!filepath) return compiled; return writeFilePromise( path.join(process.cwd(), ".github", "workflows", filepath), - compiled + compiled, ); } }