Repository: mheap/pin-github-action (js)

Issue:
Support pinning in composite actions
I wanted to use this awesome tool to pin Github Actions dependencies in a [composite action](https://docs.github.com/en/actions/creating-actions/creating-a-composite-action) like:
```yaml
name: Deploy app

inputs:
  target_environment:
    description: The env to deploy to
    required: true
  github_token:
    description: Credential (token) needed to push Git tags to current repository
    required: true

runs:
  using: composite
  steps:
  - name: Create GitHub deployment
    uses: bobheadxi/deployments@v0.6.1
    id: deployment
    with:
      step: start
      token: ${{ inputs.github_token }}
      env: ${{ inputs.target_environment }}

# etc
```

I did `npm install -g pin-github-action` followed by `pin-github-action .github/actions/deploy-app/action.yml` (by convention I store composite actions in `.github/actions` but the path shouldn't matter) which yields the error: `No jobs found`

I also tried running `pin-github-action` on a workflow that uses my composite action but as expected according to [how it works](https://github.com/mheap/pin-github-action#how-it-works) any step using a `./local-path` action is skipped so nothing changed.

It would be cool to have support for composite actions as they are a really nice feature of GHA and structurally not too different from workflows 🙇🏼 (I might send a PR but am not fluent in JavaScript)

Pull request description:
Add composite action support
Add support for pinning actions in composite `action.yml` files.

The code could use some cleanup, but the tests pass and it resolves #44 so let's :shipit: and refactor

Patch:
diff --git a/extractActions.js b/extractActions.js
index 6c0ba33..cad4c6a 100644
--- a/extractActions.js
+++ b/extractActions.js
@@ -1,4 +1,30 @@
 module.exports = function (input, allowEmpty) {
+  // Check if it's a composite action
+  let runs = input.contents.items.filter((n) => n.key == "runs");
+  if (runs.length) {
+    return extractFromComposite(input, allowEmpty);
+  }
+
+  return extractFromWorkflow(input, allowEmpty);
+};
+
+function extractFromComposite(input, allowEmpty) {
+  let runs = input.contents.items.filter((n) => n.key == "runs");
+  let steps = runs[0].value.items.filter((n) => n.key == "steps");
+
+  if (!steps.length) {
+    throw new Error("No runs.steps found");
+  }
+
+  const actions = new Set();
+  steps = steps[0].value.items;
+  for (let step of steps) {
+    handleStep(actions, step);
+  }
+  return Array.from(actions);
+}
+
+function extractFromWorkflow(input, allowEmpty) {
   let actions = new Set();
 
   let jobs = input.contents.items.filter((n) => n.key == "jobs");
@@ -21,39 +47,42 @@ module.exports = function (input, allowEmpty) {
       throw new Error("No job.steps found");
     }
 
-    let actionsDetected = false;
     for (let step of steps) {
-      const uses = step.items.filter((n) => n.key == "uses");
-      if (uses.length) {
-        actionsDetected = true;
-      }
-      for (let use of uses) {
-        const line = use.value.value.toString();
-
-        if (line.substr(0, 9) == "docker://") {
-          continue;
-        }
-        if (line.substr(0, 2) == "./") {
-          continue;
-        }
-
-        const details = parseAction(line);
-        let original = (use.value.comment || "").replace(" pin@", "");
-        if (!original) {
-          original = details.currentVersion;
-        }
-
-        actions.add({ ...details, pinnedVersion: original });
-      }
+      handleStep(actions, step);
     }
+  }
 
-    if (!actionsDetected && !allowEmpty) {
-      throw new Error("No Actions detected");
-    }
+  if (actions.size === 0 && !allowEmpty) {
+    throw new Error("No Actions detected");
   }
 
   return Array.from(actions);
-};
+}
+
+function handleStep(actions, step) {
+  const uses = step.items.filter((n) => n.key == "uses");
+
+  for (let use of uses) {
+    const line = use.value.value.toString();
+
+    if (line.substr(0, 9) == "docker://") {
+      continue;
+    }
+    if (line.substr(0, 2) == "./") {
+      continue;
+    }
+
+    const details = parseAction(line);
+    let original = (use.value.comment || "").replace(" pin@", "");
+    if (!original) {
+      original = details.currentVersion;
+    }
+
+    actions.add({ ...details, pinnedVersion: original });
+  }
+
+  return actions;
+}
 
 function parseAction(str) {
   const [name, version] = str.split("@");
diff --git a/replaceActions.js b/replaceActions.js
index 7fcf856..5886439 100644
--- a/replaceActions.js
+++ b/replaceActions.js
@@ -1,4 +1,40 @@
 module.exports = function (input, action) {
+  let runs = input.contents.items.filter((n) => n.key == "runs");
+  if (runs.length) {
+    return replaceInComposite(input, action);
+  }
+
+  return replaceInWorkflow(input, action);
+};
+
+function replaceInComposite(input, action) {
+  let actionString = `${action.owner}/${action.repo}`;
+  if (action.path) {
+    actionString += `/${action.path}`;
+  }
+
+  const replacement = `${actionString}@${action.newVersion}`;
+
+  actionString += `@${action.currentVersion}`;
+
+  const runs = input.contents.items.filter((n) => n.key == "runs")[0].value
+    .items;
+  const steps = runs.filter((n) => n.key == "steps")[0].value.items;
+
+  for (let step of steps) {
+    const uses = step.items.filter((n) => n.key == "uses");
+    for (let use of uses) {
+      if (use.value.value == actionString) {
+        use.value.value = replacement;
+        use.value.comment = ` pin@${action.pinnedVersion}`;
+      }
+    }
+  }
+
+  return input;
+}
+
+function replaceInWorkflow(input, action) {
   let actionString = `${action.owner}/${action.repo}`;
   if (action.path) {
     actionString += `/${action.path}`;
@@ -26,4 +62,4 @@ module.exports = function (input, action) {
   }
 
   return input;
-};
+}


Test patch:
diff --git a/extractActions.test.js b/extractActions.test.js
index 284b077..49452a8 100644
--- a/extractActions.test.js
+++ b/extractActions.test.js
@@ -335,6 +335,33 @@ test("does not throw when mixing run and uses", () => {
   ]);
 });
 
+test("extracts from composite actions", () => {
+  const input = convertToAst({
+    name: "Sample Composite",
+    runs: {
+      using: "composite",
+      steps: [
+        {
+          name: "With An Action",
+          uses: "mheap/test-action@master",
+        },
+      ],
+    },
+  });
+
+  const actual = extractActions(input);
+
+  expect(actual).toEqual([
+    {
+      owner: "mheap",
+      repo: "test-action",
+      path: "",
+      currentVersion: "master",
+      pinnedVersion: "master",
+    },
+  ]);
+});
+
 function convertToAst(input) {
   return YAML.parseDocument(YAML.stringify(input));
 }
diff --git a/replaceActions.test.js b/replaceActions.test.js
index c609164..403838e 100644
--- a/replaceActions.test.js
+++ b/replaceActions.test.js
@@ -10,7 +10,7 @@ const action = {
   newVersion: "sha-here",
 };
 
-test("replaces a single action with a sha", () => {
+test("replaces a single action with a sha (workflow)", () => {
   const input = convertToAst({
     name: "PR",
     on: ["pull_request"],
@@ -32,6 +32,25 @@ test("replaces a single action with a sha", () => {
   expect(actual).toContain("uses: mheap/test-action@sha-here # pin@master");
 });
 
+test("replaces a single action with a sha (composite)", () => {
+  const input = convertToAst({
+    name: "Sample Composite",
+    runs: {
+      using: "composite",
+      steps: [
+        {
+          name: "With An Action",
+          uses: "mheap/test-action@master",
+        },
+      ],
+    },
+  });
+
+  const actual = replaceActions(input, { ...action }).toString();
+
+  expect(actual).toContain("uses: mheap/test-action@sha-here # pin@master");
+});
+
 test("replaces an existing sha with a different sha, not changing the pinned branch", () => {
   const input = convertToAst({
     name: "PR",