@postman-cse/onboarding-repo-sync 2.1.10 → 2.1.13
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/README.md +1 -1
- package/dist/action.cjs +314 -79
- package/dist/cli.cjs +314 -79
- package/dist/index.cjs +314 -79
- package/package.json +4 -3
- package/scripts/verify-release-artifacts.mjs +239 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postman-cse/onboarding-repo-sync",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.13",
|
|
4
4
|
"description": "Postman repo sync GitHub Action.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"action.yml",
|
|
12
|
-
"dist"
|
|
12
|
+
"dist",
|
|
13
|
+
"scripts/verify-release-artifacts.mjs"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=24"
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"docs:tables": "node scripts/render-action-tables.mjs",
|
|
25
26
|
"lint": "eslint .",
|
|
26
27
|
"lint:fix": "eslint . --fix",
|
|
27
|
-
"test": "vitest run && node --test .github/scripts/
|
|
28
|
+
"test": "vitest run && node --test .github/scripts/dispatch-e2e-monitor.test.mjs",
|
|
28
29
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const SAFE_BASENAME = /^[A-Za-z0-9._-]+$/;
|
|
8
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Buffer|string} bytes
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function sha256Hex(bytes) {
|
|
15
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Buffer|string} bytes
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function computeNpmSri(bytes) {
|
|
23
|
+
return `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} expected
|
|
28
|
+
* @param {string} actual
|
|
29
|
+
*/
|
|
30
|
+
export function assertNpmSriMatch(expected, actual) {
|
|
31
|
+
if (expected !== actual) {
|
|
32
|
+
throw new Error('existing npm package integrity differs from staged tarball');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} tag
|
|
38
|
+
* @param {string} packageVersion
|
|
39
|
+
*/
|
|
40
|
+
export function validateTagVersion(tag, packageVersion) {
|
|
41
|
+
const [major, minor, patch] = packageVersion.split('.');
|
|
42
|
+
if (tag !== `v${packageVersion}` && !(patch === '0' && tag === `v${major}.${minor}`)) {
|
|
43
|
+
throw new Error(`tag ${tag} does not match package version ${packageVersion}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} tag
|
|
49
|
+
* @param {string} version
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function validateReleaseTag(tag, version) {
|
|
53
|
+
try {
|
|
54
|
+
validateTagVersion(tag, version);
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} packageVersion
|
|
63
|
+
* @returns {string[]}
|
|
64
|
+
*/
|
|
65
|
+
export function expectedArtifactNames(packageVersion) {
|
|
66
|
+
const sea = `postman-repo-sync-${packageVersion}-linux-x64`;
|
|
67
|
+
return ['release.tgz', sea, `${sea}.sha256`];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {unknown} value
|
|
72
|
+
* @param {string} label
|
|
73
|
+
*/
|
|
74
|
+
function assertString(value, label) {
|
|
75
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
76
|
+
throw new Error(`manifest ${label} must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {unknown} manifest
|
|
82
|
+
* @param {string} directory
|
|
83
|
+
* @param {{ repository: string, commitSha: string, tag: string, packageName?: string, packageVersion?: string }} expected
|
|
84
|
+
*/
|
|
85
|
+
export function validateManifest(manifest, directory, expected) {
|
|
86
|
+
if (!manifest || typeof manifest !== 'object') throw new Error('invalid manifest schema');
|
|
87
|
+
const body = /** @type {Record<string, unknown>} */ (manifest);
|
|
88
|
+
if (body.schema_version !== 1) throw new Error('invalid manifest schema');
|
|
89
|
+
assertString(body.repository, 'repository');
|
|
90
|
+
assertString(body.commit_sha, 'commit_sha');
|
|
91
|
+
assertString(body.tag, 'tag');
|
|
92
|
+
assertString(body.package_name, 'package_name');
|
|
93
|
+
assertString(body.package_version, 'package_version');
|
|
94
|
+
if (!Array.isArray(body.artifacts)) throw new Error('invalid manifest schema');
|
|
95
|
+
|
|
96
|
+
for (const [key, value] of Object.entries({
|
|
97
|
+
repository: expected.repository,
|
|
98
|
+
commit_sha: expected.commitSha,
|
|
99
|
+
tag: expected.tag
|
|
100
|
+
})) {
|
|
101
|
+
if (body[key] !== value) throw new Error(`manifest ${key} mismatch`);
|
|
102
|
+
}
|
|
103
|
+
if (expected.packageName !== undefined && body.package_name !== expected.packageName) {
|
|
104
|
+
throw new Error('manifest package_name mismatch');
|
|
105
|
+
}
|
|
106
|
+
if (expected.packageVersion !== undefined && body.package_version !== expected.packageVersion) {
|
|
107
|
+
throw new Error('manifest package_version mismatch');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const packageVersion = String(body.package_version);
|
|
111
|
+
const expectedNames = expectedArtifactNames(packageVersion);
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
/** @type {Array<{ path: string, sha256: string }>} */
|
|
114
|
+
const artifacts = [];
|
|
115
|
+
for (const entry of body.artifacts) {
|
|
116
|
+
if (!entry || typeof entry !== 'object') throw new Error('invalid artifact manifest entry');
|
|
117
|
+
const artifact = /** @type {Record<string, unknown>} */ (entry);
|
|
118
|
+
assertString(artifact.path, 'artifact.path');
|
|
119
|
+
assertString(artifact.sha256, 'artifact.sha256');
|
|
120
|
+
const path = String(artifact.path);
|
|
121
|
+
const digest = String(artifact.sha256);
|
|
122
|
+
if (
|
|
123
|
+
path !== basename(path) ||
|
|
124
|
+
path.includes('..') ||
|
|
125
|
+
path.includes('/') ||
|
|
126
|
+
path.includes('\\') ||
|
|
127
|
+
!SAFE_BASENAME.test(path)
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(`unsafe artifact path ${path}`);
|
|
130
|
+
}
|
|
131
|
+
if (!SHA256_HEX.test(digest)) throw new Error(`invalid artifact sha256 for ${path}`);
|
|
132
|
+
if (seen.has(path)) throw new Error(`duplicate artifact path ${path}`);
|
|
133
|
+
seen.add(path);
|
|
134
|
+
artifacts.push({ path, sha256: digest });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (artifacts.length !== expectedNames.length || expectedNames.some((name) => !seen.has(name))) {
|
|
138
|
+
throw new Error(`exact artifact allowlist mismatch; expected ${expectedNames.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onDisk = new Set(readdirSync(directory));
|
|
142
|
+
if (!onDisk.has('release-manifest.json')) throw new Error('missing release-manifest.json');
|
|
143
|
+
for (const name of expectedNames) {
|
|
144
|
+
if (!onDisk.has(name)) throw new Error(`missing artifact ${name}`);
|
|
145
|
+
}
|
|
146
|
+
for (const name of onDisk) {
|
|
147
|
+
if (name === 'release-manifest.json') continue;
|
|
148
|
+
if (!seen.has(name)) throw new Error(`unexpected filesystem entry ${name}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const artifact of artifacts) {
|
|
152
|
+
const fullPath = join(directory, artifact.path);
|
|
153
|
+
if (!existsSync(fullPath)) throw new Error(`missing artifact ${artifact.path}`);
|
|
154
|
+
if (sha256Hex(readFileSync(fullPath)) !== artifact.sha256) {
|
|
155
|
+
throw new Error(`checksum mismatch for ${artifact.path}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
validateTagVersion(String(body.tag), packageVersion);
|
|
160
|
+
validateSeaSidecar(directory, packageVersion, artifacts);
|
|
161
|
+
return body;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string} directory
|
|
166
|
+
* @param {string} packageVersion
|
|
167
|
+
* @param {Array<{ path: string, sha256: string }>} artifacts
|
|
168
|
+
*/
|
|
169
|
+
export function validateSeaSidecar(directory, packageVersion, artifacts) {
|
|
170
|
+
const sea = `postman-repo-sync-${packageVersion}-linux-x64`;
|
|
171
|
+
const sidecarName = `${sea}.sha256`;
|
|
172
|
+
const seaEntry = artifacts.find((artifact) => artifact.path === sea);
|
|
173
|
+
const sidecarEntry = artifacts.find((artifact) => artifact.path === sidecarName);
|
|
174
|
+
if (!seaEntry || !sidecarEntry) throw new Error('SEA executable and sidecar are required');
|
|
175
|
+
const sidecarText = readFileSync(join(directory, sidecarName), 'utf8').trim();
|
|
176
|
+
const [digest = '', filename = ''] = sidecarText.split(/\s+/);
|
|
177
|
+
if (!SHA256_HEX.test(digest) || filename !== sea) {
|
|
178
|
+
throw new Error(`SEA sidecar text must be "<sha256> ${sea}"`);
|
|
179
|
+
}
|
|
180
|
+
const actual = sha256Hex(readFileSync(join(directory, sea)));
|
|
181
|
+
if (
|
|
182
|
+
digest !== actual ||
|
|
183
|
+
digest !== seaEntry.sha256 ||
|
|
184
|
+
sidecarEntry.sha256 !== sha256Hex(readFileSync(join(directory, sidecarName)))
|
|
185
|
+
) {
|
|
186
|
+
throw new Error('SEA sidecar digest does not match executable and manifest');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} directory
|
|
192
|
+
*/
|
|
193
|
+
export function readTarballPackageIdentity(directory) {
|
|
194
|
+
const tarball = join(directory, 'release.tgz');
|
|
195
|
+
const packageJson = JSON.parse(
|
|
196
|
+
execFileSync('tar', ['-xOf', tarball, 'package/package.json'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
return { name: packageJson.name, version: packageJson.version };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {{ directory?: string, repository: string, commitSha: string, tag: string }} input
|
|
206
|
+
*/
|
|
207
|
+
export function verifyReleaseArtifacts({ directory = '.', repository, commitSha, tag }) {
|
|
208
|
+
const manifestPath = join(directory, 'release-manifest.json');
|
|
209
|
+
if (!existsSync(manifestPath)) throw new Error('release-manifest.json is required');
|
|
210
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
211
|
+
const identity = readTarballPackageIdentity(directory);
|
|
212
|
+
validateManifest(manifest, directory, {
|
|
213
|
+
repository,
|
|
214
|
+
commitSha,
|
|
215
|
+
tag,
|
|
216
|
+
packageName: identity.name,
|
|
217
|
+
packageVersion: identity.version
|
|
218
|
+
});
|
|
219
|
+
if (manifest.package_name !== identity.name || manifest.package_version !== identity.version) {
|
|
220
|
+
throw new Error('tarball package identity mismatch');
|
|
221
|
+
}
|
|
222
|
+
return manifest;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function main() {
|
|
226
|
+
const directory = process.argv[2];
|
|
227
|
+
if (!directory) throw new Error('usage: node scripts/verify-release-artifacts.mjs <directory>');
|
|
228
|
+
verifyReleaseArtifacts({
|
|
229
|
+
directory,
|
|
230
|
+
repository: process.env.GITHUB_REPOSITORY ?? '',
|
|
231
|
+
commitSha: process.env.GITHUB_SHA ?? '',
|
|
232
|
+
tag: process.env.GITHUB_REF_NAME ?? ''
|
|
233
|
+
});
|
|
234
|
+
console.log('release artifact manifest verified');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
238
|
+
main();
|
|
239
|
+
}
|