Skip to content

Commit c2a1472

Browse files
author
Cole Kennedy
committed
Improve action wrapper with better error handling and more robust directory detection
1 parent ac40596 commit c2a1472

7 files changed

+1104
-45
lines changed

README.md

+40-2
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,57 @@ jobs:
2828
| `action-ref` | Reference to the nested action (e.g., owner/repo@ref) | Yes |
2929
| `extra-args` | Extra arguments to pass to the nested action | No |
3030

31+
## Features
32+
33+
- **Flexible Reference Handling**: Supports both tags (e.g., `v1.0.0`) and branch names (e.g., `main`)
34+
- **Smart Extraction**: Intelligently finds the extracted directory even if naming patterns change
35+
- **Format Flexibility**: Supports both `action.yml` and `action.yaml` metadata files
36+
- **Robust Error Handling**: Attempts alternative download URLs if the first one fails
37+
- **Dependency Management**: Automatically installs dependencies for the wrapped action
38+
3139
## How It Works
3240

3341
1. **Parsing the Input:**
3442
The wrapper reads an input `action-ref` (like `"owner/repo@ref"`) and splits it into the repository identifier and ref.
3543

3644
2. **Downloading the Repository:**
37-
It constructs the URL for the GitHub zip archive and downloads it using Axios. The zip is then extracted using the `unzipper` package into a temporary directory.
45+
It constructs the URL for the GitHub zip archive, automatically handling both branch and tag references. The zip is then downloaded using Axios and extracted using the `unzipper` package into a temporary directory.
3846

3947
3. **Reading Action Metadata:**
40-
The script reads the `action.yml` file from the extracted folder to determine the JavaScript entry point (from the `runs.main` field).
48+
The script reads the action's metadata file (either `action.yml` or `action.yaml`) from the extracted folder to determine the JavaScript entry point (from the `runs.main` field).
4149

4250
4. **Dependency Installation (Optional):**
4351
If a `package.json` is present in the nested action, it runs `npm install` to install dependencies.
4452

4553
5. **Executing the Nested Action:**
4654
Finally, the wrapper runs the nested action's entry file using Node.js. Any extra arguments provided via the `extra-args` input are passed along.
55+
56+
## Examples
57+
58+
### Using with a Tagged Release
59+
60+
```yaml
61+
- name: Run Release Version
62+
uses: testifysec/action-wrapper@v1
63+
with:
64+
action-ref: "actions/[email protected]"
65+
```
66+
67+
### Using with a Branch
68+
69+
```yaml
70+
- name: Run Latest Version
71+
uses: testifysec/action-wrapper@v1
72+
with:
73+
action-ref: "actions/hello-world-javascript-action@main"
74+
```
75+
76+
### Passing Arguments
77+
78+
```yaml
79+
- name: Run with Arguments
80+
uses: testifysec/action-wrapper@v1
81+
with:
82+
action-ref: "some/action@v1"
83+
extra-args: "--input1 value1 --input2 value2"
84+
```

index.js

+112-43
Original file line numberDiff line numberDiff line change
@@ -18,66 +18,135 @@ async function run() {
1818
core.info(`Parsed repo: ${repo}, ref: ${ref}`);
1919

2020
// Construct URL for the repository zip archive
21-
const zipUrl = `https://github.com/${repo}/archive/${ref}.zip`;
21+
// Use proper URL format for GitHub archives (handle both branches and tags)
22+
const isTag = !ref.includes('/');
23+
const zipUrl = isTag
24+
? `https://github.com/${repo}/archive/refs/tags/${ref}.zip`
25+
: `https://github.com/${repo}/archive/refs/heads/${ref}.zip`;
26+
2227
core.info(`Downloading action from: ${zipUrl}`);
2328

2429
// Create a temporary directory for extraction
2530
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nested-action-"));
2631

27-
// Download and extract the zip archive
28-
const response = await axios({
29-
url: zipUrl,
30-
method: "GET",
31-
responseType: "stream"
32-
});
33-
await new Promise((resolve, reject) => {
34-
response.data
35-
.pipe(unzipper.Extract({ path: tempDir }))
36-
.on("close", resolve)
37-
.on("error", reject);
38-
});
39-
core.info(`Downloaded and extracted to ${tempDir}`);
32+
try {
33+
// Download and extract the zip archive
34+
const response = await axios({
35+
url: zipUrl,
36+
method: "GET",
37+
responseType: "stream",
38+
validateStatus: function (status) {
39+
return status >= 200 && status < 300; // Default
40+
},
41+
maxRedirects: 5 // Handle redirects
42+
});
43+
44+
await new Promise((resolve, reject) => {
45+
response.data
46+
.pipe(unzipper.Extract({ path: tempDir }))
47+
.on("close", resolve)
48+
.on("error", reject);
49+
});
50+
core.info(`Downloaded and extracted to ${tempDir}`);
51+
} catch (error) {
52+
if (error.response) {
53+
core.error(`Download failed with status ${error.response.status}`);
54+
if (isTag) {
55+
// Try alternative URL format if first attempt failed
56+
core.info("Attempting alternative download URL for branches...");
57+
const altZipUrl = `https://github.com/${repo}/archive/refs/heads/${ref}.zip`;
58+
core.info(`Trying alternative URL: ${altZipUrl}`);
59+
60+
const altResponse = await axios({
61+
url: altZipUrl,
62+
method: "GET",
63+
responseType: "stream",
64+
maxRedirects: 5
65+
});
66+
67+
await new Promise((resolve, reject) => {
68+
altResponse.data
69+
.pipe(unzipper.Extract({ path: tempDir }))
70+
.on("close", resolve)
71+
.on("error", reject);
72+
});
73+
core.info(`Downloaded and extracted from alternative URL to ${tempDir}`);
74+
} else {
75+
throw error;
76+
}
77+
} else {
78+
throw error;
79+
}
80+
}
81+
82+
// List contents of the temp directory for diagnostic purposes
83+
core.debug(`Temporary directory contents: ${fs.readdirSync(tempDir).join(', ')}`);
4084

4185
// GitHub archives typically extract to a folder named "repo-ref"
4286
const repoName = repo.split("/")[1];
4387
const extractedFolder = path.join(tempDir, `${repoName}-${ref}`);
4488
if (!fs.existsSync(extractedFolder)) {
45-
throw new Error(`Extracted folder ${extractedFolder} not found.`);
89+
// If default folder name doesn't exist, try finding based on content
90+
const tempContents = fs.readdirSync(tempDir);
91+
if (tempContents.length === 1 && fs.lstatSync(path.join(tempDir, tempContents[0])).isDirectory()) {
92+
// If there's only one directory, use that one
93+
const alternateFolder = path.join(tempDir, tempContents[0]);
94+
core.info(`Using alternative extracted folder: ${alternateFolder}`);
95+
return await processAction(alternateFolder, extraArgs);
96+
} else {
97+
throw new Error(`Extracted folder ${extractedFolder} not found and could not determine alternative.`);
98+
}
4699
}
47100

48-
// Read action.yml from the downloaded action
49-
const actionYmlPath = path.join(extractedFolder, "action.yml");
50-
if (!fs.existsSync(actionYmlPath)) {
51-
throw new Error(`action.yml not found in ${extractedFolder}`);
52-
}
53-
const actionConfig = yaml.load(fs.readFileSync(actionYmlPath, "utf8"));
54-
const entryPoint = actionConfig.runs && actionConfig.runs.main;
55-
if (!entryPoint) {
56-
throw new Error("Entry point (runs.main) not defined in action.yml");
101+
await processAction(extractedFolder, extraArgs);
102+
103+
} catch (error) {
104+
core.setFailed(`Wrapper action failed: ${error.message}`);
105+
if (error.response) {
106+
core.error(`HTTP status: ${error.response.status}`);
57107
}
58-
core.info(`Nested action entry point: ${entryPoint}`);
108+
}
109+
}
59110

60-
// Construct full path to the nested action's entry file
61-
const entryFile = path.join(extractedFolder, entryPoint);
62-
if (!fs.existsSync(entryFile)) {
63-
throw new Error(`Entry file ${entryFile} does not exist.`);
64-
}
111+
async function processAction(actionDir, extraArgs) {
112+
// Read action.yml from the downloaded action
113+
const actionYmlPath = path.join(actionDir, "action.yml");
114+
// Some actions use action.yaml instead of action.yml
115+
const actionYamlPath = path.join(actionDir, "action.yaml");
116+
117+
let actionConfig;
118+
119+
if (fs.existsSync(actionYmlPath)) {
120+
actionConfig = yaml.load(fs.readFileSync(actionYmlPath, "utf8"));
121+
} else if (fs.existsSync(actionYamlPath)) {
122+
actionConfig = yaml.load(fs.readFileSync(actionYamlPath, "utf8"));
123+
} else {
124+
throw new Error(`Neither action.yml nor action.yaml found in ${actionDir}`);
125+
}
126+
127+
const entryPoint = actionConfig.runs && actionConfig.runs.main;
128+
if (!entryPoint) {
129+
throw new Error("Entry point (runs.main) not defined in action metadata");
130+
}
131+
core.info(`Nested action entry point: ${entryPoint}`);
65132

66-
// Optionally, install dependencies if package.json exists
67-
const pkgJsonPath = path.join(extractedFolder, "package.json");
68-
if (fs.existsSync(pkgJsonPath)) {
69-
core.info("Installing dependencies for nested action...");
70-
await exec.exec("npm", ["install"], { cwd: extractedFolder });
71-
}
133+
// Construct full path to the nested action's entry file
134+
const entryFile = path.join(actionDir, entryPoint);
135+
if (!fs.existsSync(entryFile)) {
136+
throw new Error(`Entry file ${entryFile} does not exist.`);
137+
}
72138

73-
// Execute the nested action using Node.js
74-
const args = extraArgs.split(/\s+/).filter((a) => a); // split and remove empty strings
75-
core.info(`Executing nested action: node ${entryFile} ${args.join(" ")}`);
76-
await exec.exec("node", [entryFile, ...args], { cwd: extractedFolder });
77-
78-
} catch (error) {
79-
core.setFailed(`Wrapper action failed: ${error.message}`);
139+
// Optionally, install dependencies if package.json exists
140+
const pkgJsonPath = path.join(actionDir, "package.json");
141+
if (fs.existsSync(pkgJsonPath)) {
142+
core.info("Installing dependencies for nested action...");
143+
await exec.exec("npm", ["install"], { cwd: actionDir });
80144
}
145+
146+
// Execute the nested action using Node.js
147+
const args = extraArgs.split(/\s+/).filter((a) => a); // split and remove empty strings
148+
core.info(`Executing nested action: node ${entryFile} ${args.join(" ")}`);
149+
await exec.exec("node", [entryFile, ...args], { cwd: actionDir });
81150
}
82151

83152
function parseActionRef(refString) {

local-test.js

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// A simplified version of our action that runs locally
2+
const fs = require('fs');
3+
const os = require('os');
4+
const path = require('path');
5+
const axios = require('axios');
6+
const unzipper = require('unzipper');
7+
const { execSync } = require('child_process');
8+
const yaml = require('js-yaml');
9+
10+
async function run() {
11+
try {
12+
// Hard-coded inputs for testing
13+
const actionRef = 'actions/hello-world-javascript-action@main';
14+
const extraArgs = '';
15+
16+
// Parse action-ref (expects format: owner/repo@ref)
17+
const [repo, ref] = actionRef.split('@');
18+
console.log(`Parsed repo: ${repo}, ref: ${ref}`);
19+
20+
// Construct URL for the repository zip archive
21+
const zipUrl = `https://github.com/${repo}/archive/refs/heads/${ref}.zip`;
22+
console.log(`Downloading action from: ${zipUrl}`);
23+
24+
// Create a temporary directory for extraction
25+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nested-action-'));
26+
console.log(`Created temp directory: ${tempDir}`);
27+
28+
// Download and extract the zip archive
29+
const response = await axios({
30+
url: zipUrl,
31+
method: 'GET',
32+
responseType: 'stream'
33+
});
34+
35+
await new Promise((resolve, reject) => {
36+
response.data
37+
.pipe(unzipper.Extract({ path: tempDir }))
38+
.on('close', resolve)
39+
.on('error', reject);
40+
});
41+
console.log(`Downloaded and extracted to ${tempDir}`);
42+
43+
// GitHub archives typically extract to a folder named "repo-ref"
44+
const repoName = repo.split('/')[1];
45+
const extractedFolder = path.join(tempDir, `${repoName}-${ref}`);
46+
console.log(`Looking for extracted folder: ${extractedFolder}`);
47+
48+
if (!fs.existsSync(extractedFolder)) {
49+
throw new Error(`Extracted folder ${extractedFolder} not found.`);
50+
}
51+
52+
// List contents of temp directory for debugging
53+
console.log('Temp directory contents:');
54+
const files = fs.readdirSync(tempDir);
55+
console.log(files);
56+
57+
// Read action.yml from the downloaded action
58+
const actionYmlPath = path.join(extractedFolder, 'action.yml');
59+
if (!fs.existsSync(actionYmlPath)) {
60+
throw new Error(`action.yml not found in ${extractedFolder}`);
61+
}
62+
const actionConfig = yaml.load(fs.readFileSync(actionYmlPath, 'utf8'));
63+
const entryPoint = actionConfig.runs && actionConfig.runs.main;
64+
if (!entryPoint) {
65+
throw new Error('Entry point (runs.main) not defined in action.yml');
66+
}
67+
console.log(`Nested action entry point: ${entryPoint}`);
68+
69+
// Construct full path to the nested action's entry file
70+
const entryFile = path.join(extractedFolder, entryPoint);
71+
if (!fs.existsSync(entryFile)) {
72+
throw new Error(`Entry file ${entryFile} does not exist.`);
73+
}
74+
75+
// Optionally, install dependencies if package.json exists
76+
const pkgJsonPath = path.join(extractedFolder, 'package.json');
77+
if (fs.existsSync(pkgJsonPath)) {
78+
console.log('Installing dependencies for nested action...');
79+
execSync('npm install', { cwd: extractedFolder, stdio: 'inherit' });
80+
}
81+
82+
// For local testing, just show the content of the entry file
83+
console.log('Content of entry file:');
84+
const entryContent = fs.readFileSync(entryFile, 'utf8');
85+
console.log(entryContent.substring(0, 500) + '...'); // Show first 500 chars
86+
87+
console.log('Test completed successfully!');
88+
} catch (error) {
89+
console.error(`Test failed: ${error.message}`);
90+
if (error.response) {
91+
console.error(`Status: ${error.response.status}`);
92+
console.error(`Headers: ${JSON.stringify(error.response.headers)}`);
93+
}
94+
}
95+
}
96+
97+
run();

0 commit comments

Comments
 (0)