Skip to content

Commit c399da9

Browse files
authored
[ECP-9757] Magento Marketplace automation implementation (#3205)
* [ECP-9757] Create automation script * [ECP-9757] Delete unnecessary payload template file * [ECP-9757] Download zipball and submit package to EQP * [ECP-9757] Execute the script on release published event * [ECP-9757] Add workflow permission and timeout * [ECP-9757] Update script execution path * [ECP-9722] Use commit hash of the tag instead of the version tag
1 parent d9fb438 commit c399da9

File tree

3 files changed

+371
-34
lines changed

3 files changed

+371
-34
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
const path = require('path');
2+
const fs = require('fs');
3+
const https = require('https');
4+
const { pipeline } = require('stream/promises');
5+
const crypto = require('crypto');
6+
7+
const GITHUB_API_URL = 'api.github.com';
8+
const ADOBE_EQP_API_URL = 'commercedeveloper-api.adobe.com';
9+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
10+
const GITHUB_REPO = process.env.GITHUB_REPO;
11+
const RELEASE_TAG = process.env.RELEASE_TAG;
12+
const ADOBE_EQP_APP_ID = process.env.ADOBE_EQP_APP_ID;
13+
const ADOBE_EQP_APP_SECRET = process.env.ADOBE_EQP_APP_SECRET;
14+
const ADOBE_EQP_PACKAGE_SKU = process.env.ADOBE_EQP_PACKAGE_SKU;
15+
const LICENSE_TYPE = process.env.LICENSE_TYPE || 'mit';
16+
17+
/**
18+
* Cleans up release notes by removing GitHub-generated comments and emojis
19+
*
20+
* @param {string} notes - Raw release notes from GitHub
21+
*
22+
* @returns {string} - Cleaned release notes
23+
*/
24+
function cleanupReleaseNotes(notes) {
25+
if (!notes) return '';
26+
27+
return notes
28+
.replace(/<!-- Release notes generated using configuration in \.github\/release\.yml at .* -->/g, '')
29+
.replace(/💎/g, '')
30+
.replace(/🖇/g, '')
31+
.replace(//g, '')
32+
.trim();
33+
}
34+
35+
/**
36+
* Makes an HTTPS request and returns a promise
37+
*
38+
* @param {Object} options - Request options
39+
* @param {string|null} postData - Data to send in POST request
40+
*
41+
* @returns {Promise<Object>} - Response data
42+
*/
43+
function makeRequest(options, postData = null) {
44+
return new Promise((resolve, reject) => {
45+
const req = https.request(options, (res) => {
46+
let data = '';
47+
48+
res.on('data', (chunk) => {
49+
data += chunk;
50+
});
51+
52+
res.on('end', () => {
53+
try {
54+
const jsonData = JSON.parse(data);
55+
if (res.statusCode >= 200 && res.statusCode < 300) {
56+
resolve(jsonData);
57+
} else {
58+
reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(jsonData)}`));
59+
}
60+
} catch (e) {
61+
reject(new Error(`Failed to parse response: ${data}`));
62+
}
63+
});
64+
});
65+
66+
req.on('error', (e) => {
67+
reject(e);
68+
});
69+
70+
if (postData) {
71+
req.write(postData);
72+
}
73+
74+
req.end();
75+
});
76+
}
77+
78+
/**
79+
* Gets release information from GitHub API for a given tag
80+
*
81+
* @returns {Promise<Object>} - Release information
82+
*/
83+
async function getGithubRelease() {
84+
const options = {
85+
hostname: GITHUB_API_URL,
86+
path: `/repos/${GITHUB_REPO}/releases/tags/${RELEASE_TAG}`,
87+
method: 'GET',
88+
headers: {
89+
'Authorization': `Bearer ${GITHUB_TOKEN}`,
90+
'User-Agent': 'Adyen-Magento2-Release-Script'
91+
}
92+
};
93+
94+
return makeRequest(options);
95+
}
96+
97+
/**
98+
* Gets access token from Adobe EQP API
99+
*
100+
* @returns {Promise<Object>} - Access token response
101+
*/
102+
async function getAdobeEQPAccessToken() {
103+
const credentials = Buffer.from(`${ADOBE_EQP_APP_ID}:${ADOBE_EQP_APP_SECRET}`).toString('base64');
104+
105+
const postData = JSON.stringify({
106+
grant_type: "session",
107+
expires_in: 300
108+
});
109+
110+
const options = {
111+
hostname: ADOBE_EQP_API_URL,
112+
path: '/rest/v1/app/session/token',
113+
method: 'POST',
114+
headers: {
115+
'Authorization': `Basic ${credentials}`,
116+
'Content-Type': 'application/json',
117+
'Content-Length': Buffer.byteLength(postData)
118+
}
119+
};
120+
121+
return makeRequest(options, postData);
122+
}
123+
124+
/**
125+
* Downloads a release zipball from GitHub and saves it to a file
126+
*
127+
* @param {string} url - The zipball URL from GitHub
128+
* @param {string} destPath - Destination file path
129+
*
130+
* @returns {Promise<void>}
131+
*/
132+
async function downloadReleaseZipball(url, destPath) {
133+
const response = await new Promise((resolve, reject) => {
134+
https.get(url, {
135+
headers: {
136+
'Authorization': `Bearer ${GITHUB_TOKEN}`,
137+
'User-Agent': 'Adyen-Magento2-Release-Script',
138+
'Accept': 'application/vnd.github+json'
139+
}
140+
}, resolve).on('error', reject);
141+
});
142+
143+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
144+
return downloadReleaseZipball(response.headers.location, destPath);
145+
}
146+
147+
if (response.statusCode !== 200) {
148+
throw new Error(`Failed to download the zipball!`);
149+
}
150+
151+
await pipeline(response, fs.createWriteStream(destPath));
152+
}
153+
154+
/**
155+
* Uploads a zipball to Adobe EQP and returns the file upload ID
156+
*
157+
* @param {string} accessToken - Adobe EQP access token
158+
* @param {string} filePath - Path to the zip file to upload
159+
*
160+
* @returns {Promise<Object>} - Upload response containing file_upload_id
161+
*/
162+
function uploadZipballToAdobeEQP(accessToken, filePath) {
163+
const fileName = path.basename(filePath);
164+
const fileBuffer = fs.readFileSync(filePath);
165+
const boundary = crypto.randomUUID();
166+
167+
const bodyStart = Buffer.from(
168+
`--${boundary}\r\n` +
169+
`Content-Disposition: form-data; name="file[]"; filename="${fileName}"\r\n` +
170+
`Content-Type: application/zip\r\n\r\n`
171+
);
172+
const bodyEnd = Buffer.from(`\r\n--${boundary}--`);
173+
const body = Buffer.concat([bodyStart, fileBuffer, bodyEnd]);
174+
175+
const options = {
176+
hostname: ADOBE_EQP_API_URL,
177+
path: '/rest/v1/files/uploads',
178+
method: 'POST',
179+
headers: {
180+
'Authorization': `Bearer ${accessToken}`,
181+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
182+
'Content-Length': body.length
183+
}
184+
};
185+
186+
return makeRequest(options, body);
187+
}
188+
189+
/**
190+
* Checks the status of a file upload on Adobe EQP
191+
*
192+
* @param {string} accessToken - Adobe EQP access token
193+
* @param {string} fileUploadId - The file upload ID to check
194+
*
195+
* @returns {Promise<Object>} - Upload status response
196+
*/
197+
function getFileUploadStatus(accessToken, fileUploadId) {
198+
const options = {
199+
hostname: ADOBE_EQP_API_URL,
200+
path: `/rest/v1/files/uploads/${fileUploadId}`,
201+
method: 'GET',
202+
headers: {
203+
'Authorization': `Bearer ${accessToken}`
204+
}
205+
};
206+
207+
return makeRequest(options);
208+
}
209+
210+
/**
211+
* Waits for malware scan to complete with polling
212+
*
213+
* @param {string} accessToken - Adobe EQP access token
214+
* @param {string} fileUploadId - The file upload ID to check
215+
* @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 120000)
216+
* @param {number} intervalMs - Polling interval in milliseconds (default: 10000)
217+
*
218+
* @returns {Promise<Object>} - Final upload status response
219+
*/
220+
async function waitForMalwareScan(
221+
accessToken,
222+
fileUploadId,
223+
timeoutMs = 240000,
224+
intervalMs = 15000
225+
) {
226+
const startTime = Date.now();
227+
228+
while (Date.now() - startTime < timeoutMs) {
229+
const status = await getFileUploadStatus(accessToken, fileUploadId);
230+
const malwareStatus = status.malware_status;
231+
232+
if (malwareStatus === 'passed') {
233+
return status;
234+
}
235+
236+
if (malwareStatus === 'in-progress' || malwareStatus === 'queued') {
237+
await new Promise(resolve => setTimeout(resolve, intervalMs));
238+
continue;
239+
}
240+
241+
throw new Error(`Unexpected malware_status: ${malwareStatus}`);
242+
}
243+
244+
throw new Error(`Malware scan timed out after ${timeoutMs / 1000} seconds`);
245+
}
246+
247+
/**
248+
* Submits a package to Adobe EQP Marketplace
249+
*
250+
* @param {string} accessToken - Adobe EQP access token
251+
* @param {string} fileUploadId - The file upload ID from uploadZipballToAdobeEQP
252+
* @param {string} version - Package version
253+
* @param {string} releaseNotes - Release notes for the package
254+
*
255+
* @returns {Promise<Object>} - Submission response
256+
*/
257+
function submitPackageToAdobeEQP(
258+
accessToken,
259+
fileUploadId,
260+
version,
261+
releaseNotes
262+
) {
263+
const payload = [
264+
{
265+
action: {
266+
technical: "submit"
267+
},
268+
type: "extension",
269+
platform: "M2",
270+
release_notes: releaseNotes,
271+
version: version,
272+
artifact: {
273+
file_upload_id: fileUploadId
274+
},
275+
license_type: LICENSE_TYPE,
276+
sku: ADOBE_EQP_PACKAGE_SKU
277+
}
278+
];
279+
280+
const postData = JSON.stringify(payload);
281+
282+
const options = {
283+
hostname: ADOBE_EQP_API_URL,
284+
path: '/rest/v1/products/packages',
285+
method: 'POST',
286+
headers: {
287+
'Authorization': `Bearer ${accessToken}`,
288+
'Content-Type': 'application/json',
289+
'Content-Length': Buffer.byteLength(postData)
290+
}
291+
};
292+
293+
return makeRequest(options, postData);
294+
}
295+
296+
/**
297+
* Main function to orchestrate the release process
298+
*/
299+
async function main() {
300+
if (!RELEASE_TAG || !ADOBE_EQP_APP_ID || !ADOBE_EQP_APP_SECRET || !GITHUB_TOKEN) {
301+
console.error('Missing required environment variables');
302+
process.exit(1);
303+
}
304+
305+
let releaseNotes = '';
306+
307+
try {
308+
// Fetch release information from GitHub and access token from Adobe EQP
309+
const releaseInfo = await getGithubRelease();
310+
const tokenResponse = await getAdobeEQPAccessToken();
311+
312+
// Download the release zipball from GitHub
313+
const zipballPath = path.join(process.cwd(), `${RELEASE_TAG}.zip`);
314+
await downloadReleaseZipball(releaseInfo.zipball_url, zipballPath);
315+
316+
// Upload the zipball to Adobe EQP
317+
const [{ file_upload_id }] = await uploadZipballToAdobeEQP(tokenResponse.ust, zipballPath);
318+
319+
// Wait for malware scan to complete
320+
await waitForMalwareScan(tokenResponse.ust, file_upload_id);
321+
322+
// Submit the package to Adobe EQP
323+
const submitResponse = await submitPackageToAdobeEQP(
324+
tokenResponse.ust,
325+
file_upload_id,
326+
releaseInfo.tag_name.replace(/^v/, ''),
327+
cleanupReleaseNotes(releaseInfo.body)
328+
);
329+
330+
return;
331+
} catch (error) {
332+
process.exit(1);
333+
}
334+
}
335+
336+
// Run the main function
337+
main().then(() => {
338+
console.log('Process completed successfully!');
339+
}).catch((error) => {
340+
console.error('Unexpected error!');
341+
process.exit(1);
342+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Adobe EQP Marketplace Release
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
marketplace-release:
9+
runs-on: ubuntu-latest
10+
timeout-minutes: 10
11+
permissions:
12+
contents: read
13+
14+
steps:
15+
- uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2
16+
17+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
18+
with:
19+
node-version: 20
20+
21+
- name: Run Magento Marketplace release automation script
22+
run: node .github/scripts/marketplace-release.js
23+
env:
24+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25+
GITHUB_REPO: ${{ github.repository }}
26+
RELEASE_TAG: ${{ github.ref_name }}
27+
ADOBE_EQP_APP_ID: ${{ secrets.ADOBE_CLIENT_ID }}
28+
ADOBE_EQP_APP_SECRET: ${{ secrets.ADOBE_CLIENT_SECRET }}
29+
ADOBE_EQP_PACKAGE_SKU: adyen/Adyen_Payment

0 commit comments

Comments
 (0)