ready-to-ship 1.0.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/ENABLE_2FA.md +90 -0
- package/ENABLE_2FA_SECURITY_KEY.md +109 -0
- package/GO_LIVE.md +187 -0
- package/LICENSE +22 -0
- package/PUBLISH.md +110 -0
- package/PUBLISH_STEPS.md +106 -0
- package/QUICKSTART.md +71 -0
- package/README.md +196 -0
- package/UNIQUE_FEATURES.md +114 -0
- package/package.json +53 -0
- package/publish.sh +64 -0
- package/src/cli.js +155 -0
- package/src/modules/api.js +152 -0
- package/src/modules/auth.js +152 -0
- package/src/modules/database.js +178 -0
- package/src/modules/dependencies.js +151 -0
- package/src/modules/env.js +117 -0
- package/src/modules/project.js +175 -0
- package/src/modules/report.js +118 -0
- package/src/modules/security.js +149 -0
- package/src/utils/fileHelpers.js +95 -0
- package/src/utils/fixHelpers.js +203 -0
- package/src/utils/logHelpers.js +77 -0
- package/src/utils/parseHelpers.js +151 -0
- package/templates/.github/workflows/ready-to-ship.yml +35 -0
- package/templates/README.md +23 -0
- package/templates/github-actions.yml +35 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { fileExists, readFile } = require('./fileHelpers');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate auto-fix suggestions
|
|
7
|
+
*/
|
|
8
|
+
async function generateFixes(issues, projectPath) {
|
|
9
|
+
const fixes = [];
|
|
10
|
+
|
|
11
|
+
issues.forEach(issue => {
|
|
12
|
+
const issueLower = issue.toLowerCase();
|
|
13
|
+
|
|
14
|
+
// .env.example fixes
|
|
15
|
+
if (issueLower.includes('.env.example')) {
|
|
16
|
+
fixes.push({
|
|
17
|
+
type: 'create_file',
|
|
18
|
+
file: '.env.example',
|
|
19
|
+
content: generateEnvExampleTemplate(),
|
|
20
|
+
description: 'Create .env.example file'
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// README fixes
|
|
25
|
+
if (issueLower.includes('readme')) {
|
|
26
|
+
fixes.push({
|
|
27
|
+
type: 'create_file',
|
|
28
|
+
file: 'README.md',
|
|
29
|
+
content: generateReadmeTemplate(),
|
|
30
|
+
description: 'Create README.md file'
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Weak secret fixes
|
|
35
|
+
if (issueLower.includes('weak secret')) {
|
|
36
|
+
const match = issue.match(/WEAK SECRET: (\w+)/);
|
|
37
|
+
if (match) {
|
|
38
|
+
fixes.push({
|
|
39
|
+
type: 'suggestion',
|
|
40
|
+
description: `Generate a strong ${match[1]} using: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Missing env variable fixes
|
|
46
|
+
if (issueLower.includes('missing:')) {
|
|
47
|
+
const match = issue.match(/MISSING: (\w+)/);
|
|
48
|
+
if (match) {
|
|
49
|
+
fixes.push({
|
|
50
|
+
type: 'suggestion',
|
|
51
|
+
description: `Add ${match[1]} to your .env file`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Security headers fixes
|
|
57
|
+
if (issueLower.includes('security headers')) {
|
|
58
|
+
fixes.push({
|
|
59
|
+
type: 'suggestion',
|
|
60
|
+
description: 'Install and configure Helmet.js: npm install helmet && app.use(require("helmet")())'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// CORS fixes
|
|
65
|
+
if (issueLower.includes('cors')) {
|
|
66
|
+
fixes.push({
|
|
67
|
+
type: 'suggestion',
|
|
68
|
+
description: 'Install and configure CORS: npm install cors && app.use(require("cors")({ origin: process.env.ALLOWED_ORIGINS?.split(",") }))'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rate limiting fixes
|
|
73
|
+
if (issueLower.includes('rate limit')) {
|
|
74
|
+
fixes.push({
|
|
75
|
+
type: 'suggestion',
|
|
76
|
+
description: 'Install express-rate-limit: npm install express-rate-limit && app.use(require("express-rate-limit")({ windowMs: 15 * 60 * 1000, max: 100 }))'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Health endpoint fixes
|
|
81
|
+
if (issueLower.includes('health endpoint')) {
|
|
82
|
+
fixes.push({
|
|
83
|
+
type: 'suggestion',
|
|
84
|
+
description: 'Add health endpoint: app.get("/health", (req, res) => res.json({ status: "ok", timestamp: Date.now() }))'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return fixes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate .env.example template
|
|
94
|
+
*/
|
|
95
|
+
function generateEnvExampleTemplate() {
|
|
96
|
+
return `# Environment Variables
|
|
97
|
+
# Copy this file to .env and fill in your values
|
|
98
|
+
|
|
99
|
+
# Server
|
|
100
|
+
PORT=3000
|
|
101
|
+
NODE_ENV=development
|
|
102
|
+
|
|
103
|
+
# Database
|
|
104
|
+
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
|
105
|
+
|
|
106
|
+
# JWT
|
|
107
|
+
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
|
|
108
|
+
JWT_EXPIRY=7d
|
|
109
|
+
|
|
110
|
+
# CORS
|
|
111
|
+
ALLOWED_ORIGINS=http://localhost:3000
|
|
112
|
+
|
|
113
|
+
# API Keys (if needed)
|
|
114
|
+
# API_KEY=your-api-key
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate README template
|
|
120
|
+
*/
|
|
121
|
+
function generateReadmeTemplate() {
|
|
122
|
+
return `# Project Name
|
|
123
|
+
|
|
124
|
+
Brief description of your project.
|
|
125
|
+
|
|
126
|
+
## Installation
|
|
127
|
+
|
|
128
|
+
\`\`\`bash
|
|
129
|
+
npm install
|
|
130
|
+
\`\`\`
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
Copy \`.env.example\` to \`.env\` and configure your environment variables.
|
|
135
|
+
|
|
136
|
+
\`\`\`bash
|
|
137
|
+
cp .env.example .env
|
|
138
|
+
\`\`\`
|
|
139
|
+
|
|
140
|
+
## Usage
|
|
141
|
+
|
|
142
|
+
\`\`\`bash
|
|
143
|
+
npm start
|
|
144
|
+
\`\`\`
|
|
145
|
+
|
|
146
|
+
## API Endpoints
|
|
147
|
+
|
|
148
|
+
- \`GET /health\` - Health check endpoint
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Apply fixes (with user confirmation in real usage)
|
|
158
|
+
*/
|
|
159
|
+
async function applyFixes(fixes, projectPath, dryRun = true) {
|
|
160
|
+
const results = [];
|
|
161
|
+
|
|
162
|
+
for (const fix of fixes) {
|
|
163
|
+
if (fix.type === 'create_file') {
|
|
164
|
+
const filePath = path.join(projectPath, fix.file);
|
|
165
|
+
const exists = await fileExists(filePath);
|
|
166
|
+
|
|
167
|
+
if (exists && !dryRun) {
|
|
168
|
+
results.push({
|
|
169
|
+
fix,
|
|
170
|
+
status: 'skipped',
|
|
171
|
+
reason: 'File already exists'
|
|
172
|
+
});
|
|
173
|
+
} else if (dryRun) {
|
|
174
|
+
results.push({
|
|
175
|
+
fix,
|
|
176
|
+
status: 'would_create',
|
|
177
|
+
filePath
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
await fs.writeFile(filePath, fix.content);
|
|
181
|
+
results.push({
|
|
182
|
+
fix,
|
|
183
|
+
status: 'created',
|
|
184
|
+
filePath
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
} else if (fix.type === 'suggestion') {
|
|
188
|
+
results.push({
|
|
189
|
+
fix,
|
|
190
|
+
status: 'suggestion',
|
|
191
|
+
description: fix.description
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
generateFixes,
|
|
201
|
+
applyFixes
|
|
202
|
+
};
|
|
203
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print success message
|
|
5
|
+
*/
|
|
6
|
+
function success(message) {
|
|
7
|
+
console.log(chalk.green('✅'), message);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Print error message
|
|
12
|
+
*/
|
|
13
|
+
function error(message) {
|
|
14
|
+
console.log(chalk.red('❌'), message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Print warning message
|
|
19
|
+
*/
|
|
20
|
+
function warning(message) {
|
|
21
|
+
console.log(chalk.yellow('⚠️'), message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Print info message
|
|
26
|
+
*/
|
|
27
|
+
function info(message) {
|
|
28
|
+
console.log(chalk.blue('ℹ️'), message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Print section header
|
|
33
|
+
*/
|
|
34
|
+
function header(message) {
|
|
35
|
+
console.log(chalk.bold.cyan('\n' + '='.repeat(50)));
|
|
36
|
+
console.log(chalk.bold.cyan(message));
|
|
37
|
+
console.log(chalk.bold.cyan('='.repeat(50) + '\n'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Print verdict
|
|
42
|
+
*/
|
|
43
|
+
function verdict(passed, message) {
|
|
44
|
+
if (passed) {
|
|
45
|
+
console.log(chalk.bold.green('\n✅ ' + message));
|
|
46
|
+
} else {
|
|
47
|
+
console.log(chalk.bold.red('\n❌ ' + message));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Print list of issues
|
|
53
|
+
*/
|
|
54
|
+
function printIssues(issues, type = 'error') {
|
|
55
|
+
if (issues.length === 0) return;
|
|
56
|
+
|
|
57
|
+
issues.forEach(issue => {
|
|
58
|
+
if (type === 'error') {
|
|
59
|
+
error(issue);
|
|
60
|
+
} else if (type === 'warning') {
|
|
61
|
+
warning(issue);
|
|
62
|
+
} else {
|
|
63
|
+
info(issue);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
success,
|
|
70
|
+
error,
|
|
71
|
+
warning,
|
|
72
|
+
info,
|
|
73
|
+
header,
|
|
74
|
+
verdict,
|
|
75
|
+
printIssues
|
|
76
|
+
};
|
|
77
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if string is a valid URL
|
|
3
|
+
*/
|
|
4
|
+
function isValidUrl(str) {
|
|
5
|
+
try {
|
|
6
|
+
new URL(str);
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if string is a valid email
|
|
15
|
+
*/
|
|
16
|
+
function isValidEmail(str) {
|
|
17
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
18
|
+
return emailRegex.test(str);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if string is a valid number
|
|
23
|
+
*/
|
|
24
|
+
function isValidNumber(str) {
|
|
25
|
+
return !isNaN(str) && !isNaN(parseFloat(str));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract route definitions from code
|
|
30
|
+
*/
|
|
31
|
+
function extractRoutes(code) {
|
|
32
|
+
const routes = [];
|
|
33
|
+
|
|
34
|
+
// Match common route patterns
|
|
35
|
+
const patterns = [
|
|
36
|
+
/(?:app|router)\.(get|post|put|delete|patch)\s*\(['"`]([^'"`]+)['"`]/g,
|
|
37
|
+
/(?:app|router)\.(get|post|put|delete|patch)\s*\(['"`]([^'"`]+)['"`]/g,
|
|
38
|
+
/Route\.(get|post|put|delete|patch)\s*\(['"`]([^'"`]+)['"`]/g,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
patterns.forEach(pattern => {
|
|
42
|
+
let match;
|
|
43
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
44
|
+
routes.push({
|
|
45
|
+
method: match[1].toUpperCase(),
|
|
46
|
+
path: match[2],
|
|
47
|
+
line: code.substring(0, match.index).split('\n').length
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return routes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if route has auth middleware
|
|
57
|
+
*/
|
|
58
|
+
function hasAuthMiddleware(code, routeIndex) {
|
|
59
|
+
const lines = code.split('\n');
|
|
60
|
+
const routeLine = lines[routeIndex - 1] || '';
|
|
61
|
+
|
|
62
|
+
// Check for common auth middleware patterns
|
|
63
|
+
const authPatterns = [
|
|
64
|
+
/authenticate|auth|jwt|verifyToken|requireAuth|isAuthenticated/i,
|
|
65
|
+
/passport\.authenticate/,
|
|
66
|
+
/middleware.*auth/i
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Check the route line and a few lines before/after
|
|
70
|
+
const context = lines.slice(Math.max(0, routeIndex - 3), routeIndex + 3).join('\n');
|
|
71
|
+
|
|
72
|
+
return authPatterns.some(pattern => pattern.test(context));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract middleware usage
|
|
77
|
+
*/
|
|
78
|
+
function extractMiddleware(code) {
|
|
79
|
+
const middleware = [];
|
|
80
|
+
const patterns = [
|
|
81
|
+
/\.use\s*\(['"`]([^'"`]+)['"`]/g,
|
|
82
|
+
/\.use\s*\(([a-zA-Z_$][a-zA-Z0-9_$]*)/g
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
patterns.forEach(pattern => {
|
|
86
|
+
let match;
|
|
87
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
88
|
+
middleware.push(match[1]);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return middleware;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if code has error handling
|
|
97
|
+
*/
|
|
98
|
+
function hasErrorHandling(code) {
|
|
99
|
+
const errorPatterns = [
|
|
100
|
+
/catch\s*\(/,
|
|
101
|
+
/\.catch\s*\(/,
|
|
102
|
+
/errorHandler|error-handler|errorMiddleware/i,
|
|
103
|
+
/express\.errorHandler/,
|
|
104
|
+
/app\.use.*error/i
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
return errorPatterns.some(pattern => pattern.test(code));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check JWT expiry configuration
|
|
112
|
+
*/
|
|
113
|
+
function extractJWTExpiry(code) {
|
|
114
|
+
const patterns = [
|
|
115
|
+
/expiresIn\s*[:=]\s*['"`]?(\d+)\s*([a-z]+)['"`]?/i,
|
|
116
|
+
/expiresIn\s*[:=]\s*(\d+)/i,
|
|
117
|
+
/JWT_EXPIRY\s*[:=]\s*['"`]?(\d+)\s*([a-z]+)['"`]?/i
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const pattern of patterns) {
|
|
121
|
+
const match = code.match(pattern);
|
|
122
|
+
if (match) {
|
|
123
|
+
const value = parseInt(match[1]);
|
|
124
|
+
const unit = (match[2] || 's').toLowerCase();
|
|
125
|
+
|
|
126
|
+
// Convert to seconds
|
|
127
|
+
let seconds = value;
|
|
128
|
+
if (unit.includes('year') || unit === 'y') seconds = value * 365 * 24 * 60 * 60;
|
|
129
|
+
else if (unit.includes('month') || unit === 'm') seconds = value * 30 * 24 * 60 * 60;
|
|
130
|
+
else if (unit.includes('day') || unit === 'd') seconds = value * 24 * 60 * 60;
|
|
131
|
+
else if (unit.includes('hour') || unit === 'h') seconds = value * 60 * 60;
|
|
132
|
+
else if (unit.includes('minute') || unit === 'min') seconds = value * 60;
|
|
133
|
+
|
|
134
|
+
return seconds;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
isValidUrl,
|
|
143
|
+
isValidEmail,
|
|
144
|
+
isValidNumber,
|
|
145
|
+
extractRoutes,
|
|
146
|
+
hasAuthMiddleware,
|
|
147
|
+
extractMiddleware,
|
|
148
|
+
hasErrorHandling,
|
|
149
|
+
extractJWTExpiry
|
|
150
|
+
};
|
|
151
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Ready-to-Ship Validation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master, develop ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master, develop ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
validate:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v3
|
|
15
|
+
|
|
16
|
+
- name: Setup Node.js
|
|
17
|
+
uses: actions/setup-node@v3
|
|
18
|
+
with:
|
|
19
|
+
node-version: '18'
|
|
20
|
+
cache: 'npm'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Run Ready-to-Ship
|
|
26
|
+
run: npx ready-to-ship report --json
|
|
27
|
+
continue-on-error: true
|
|
28
|
+
|
|
29
|
+
- name: Upload report
|
|
30
|
+
uses: actions/upload-artifact@v3
|
|
31
|
+
if: always()
|
|
32
|
+
with:
|
|
33
|
+
name: ready-to-ship-report
|
|
34
|
+
path: ready-to-ship-report.json
|
|
35
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Templates
|
|
2
|
+
|
|
3
|
+
This directory contains ready-to-use templates for CI/CD integration.
|
|
4
|
+
|
|
5
|
+
## GitHub Actions
|
|
6
|
+
|
|
7
|
+
Copy `github-actions.yml` to `.github/workflows/ready-to-ship.yml` in your project:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
mkdir -p .github/workflows
|
|
11
|
+
cp templates/github-actions.yml .github/workflows/ready-to-ship.yml
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This will automatically run Ready-to-Ship validation on every push and PR.
|
|
15
|
+
|
|
16
|
+
## Customization
|
|
17
|
+
|
|
18
|
+
You can customize the workflow to:
|
|
19
|
+
- Run only on specific branches
|
|
20
|
+
- Add notifications (Slack, email)
|
|
21
|
+
- Upload reports as artifacts
|
|
22
|
+
- Block merges if validation fails
|
|
23
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Ready-to-Ship Validation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master, develop ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master, develop ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
validate:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v3
|
|
15
|
+
|
|
16
|
+
- name: Setup Node.js
|
|
17
|
+
uses: actions/setup-node@v3
|
|
18
|
+
with:
|
|
19
|
+
node-version: '18'
|
|
20
|
+
cache: 'npm'
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Run Ready-to-Ship
|
|
26
|
+
run: npx ready-to-ship report --json
|
|
27
|
+
continue-on-error: true
|
|
28
|
+
|
|
29
|
+
- name: Upload report
|
|
30
|
+
uses: actions/upload-artifact@v3
|
|
31
|
+
if: always()
|
|
32
|
+
with:
|
|
33
|
+
name: ready-to-ship-report
|
|
34
|
+
path: ready-to-ship-report.json
|
|
35
|
+
|