forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8
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/CHANGELOG.md +24 -0
- package/bin/forge-gh-proxy.js +15 -0
- package/bin/forge-github-credential.js +21 -0
- package/bin/forge.js +16 -1
- package/docs/guides/MIGRATION.md +4 -3
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/VALIDATION.md +14 -0
- package/docs/reference/github-accounts.md +114 -38
- package/lib/bun-workflow-pins.js +129 -3
- package/lib/commands/github.js +234 -49
- package/lib/commands/push.js +298 -46
- package/lib/commands/release.js +18 -1
- package/lib/commands/test.js +8 -0
- package/lib/commands/validate.js +209 -69
- package/lib/gh-proxy.js +309 -0
- package/lib/github-context.js +160 -25
- package/lib/github-credential.js +47 -0
- package/lib/github-router.js +774 -0
- package/lib/native-gh.js +177 -0
- package/lib/npm-publish-workflow.js +5 -5
- package/lib/pr-monitor/flow-monitor.js +125 -26
- package/lib/protected-state-authority.js +214 -13
- package/lib/release-readiness.js +57 -41
- package/lib/shell-utils.js +41 -15
- package/lib/test-workflow.js +389 -0
- package/lib/validation-receipt.js +127 -12
- package/lib/workflow-templates/test.yml +654 -0
- package/package.json +7 -5
- package/scripts/check-forge-token.js +85 -67
- package/scripts/gen-embedded-assets.mjs +31 -5
- package/scripts/test-full-suite.js +69 -15
- package/scripts/test.js +10 -0
package/lib/github-context.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const { isManagedCredentialHelperValue, isOwnedCredentialHelperValue, isUsableCredentialHelperValue } = require('./github-router');
|
|
5
|
+
const { resolveRealGh } = require('./native-gh');
|
|
4
6
|
|
|
5
7
|
const ACCOUNT_KEY = 'github.account';
|
|
8
|
+
const AUTO_KEY = 'github.auto';
|
|
9
|
+
const CREDENTIAL_HELPER_KEY = 'credential.https://github.com.helper';
|
|
6
10
|
const HOST = 'github.com';
|
|
7
11
|
const LOGIN_RE = /^(?=.{1,39}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
8
|
-
const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_host', 'gh_repo']);
|
|
9
|
-
const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token']);
|
|
12
|
+
const GITHUB_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_enterprise_token', 'github_enterprise_token', 'gh_host', 'gh_repo']);
|
|
13
|
+
const GITHUB_SECRET_ENV_NAMES = new Set(['gh_token', 'github_token', 'gh_enterprise_token', 'github_enterprise_token']);
|
|
10
14
|
|
|
11
15
|
class GithubContextError extends Error {
|
|
12
16
|
constructor(code, message) {
|
|
@@ -29,16 +33,17 @@ function safeLogin(login) {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
function runnerFor(options) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const injected = options.runner || options._runner || options._exec;
|
|
37
|
+
if (injected) return injected;
|
|
38
|
+
const execute = options.execFileSync || execFileSync;
|
|
39
|
+
const resolve = options.resolveRealGh || resolveRealGh;
|
|
40
|
+
return (command, args, runOptions = {}) => {
|
|
41
|
+
const executable = command === 'gh' ? resolve({ env: runOptions.env }) : command;
|
|
42
|
+
if (!executable) throw Object.assign(new Error('GitHub CLI was not found.'), { code: 'ENOENT' });
|
|
43
|
+
return execute(executable, args, {
|
|
44
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...runOptions,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
function errorText(error) {
|
|
@@ -86,35 +91,50 @@ function gitConfig(projectRoot, args, options, absentIsOkay = false) {
|
|
|
86
91
|
windowsHide: true,
|
|
87
92
|
});
|
|
88
93
|
} catch (error) {
|
|
89
|
-
if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error)))) {
|
|
90
|
-
return
|
|
94
|
+
if (absentIsOkay && (error?.status === 1 || /key does not contain|not found|no such key|not a git repository|--local can only be used/i.test(errorText(error)))) {
|
|
95
|
+
return null;
|
|
91
96
|
}
|
|
92
97
|
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to read this clone-local GitHub account binding.');
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
function
|
|
97
|
-
const output = gitConfig(projectRoot, ['--get',
|
|
98
|
-
|
|
99
|
-
return
|
|
101
|
+
function readLocalValues(projectRoot, key, options = {}) {
|
|
102
|
+
const output = gitConfig(projectRoot, ['--get-all', key], options, true);
|
|
103
|
+
if (output == null) return [];
|
|
104
|
+
return output.toString().replace(/\r?\n$/, '').split(/\r?\n/);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
function
|
|
103
|
-
if (!validateGithubLogin(login)) {
|
|
104
|
-
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
|
|
105
|
-
}
|
|
107
|
+
function runGitConfig(projectRoot, args, options, message) {
|
|
106
108
|
try {
|
|
107
|
-
runnerFor(options)('git', ['config', '--local',
|
|
109
|
+
return runnerFor(options)('git', ['config', '--local', ...args], {
|
|
108
110
|
cwd: projectRoot,
|
|
109
111
|
encoding: 'utf8',
|
|
110
112
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
113
|
windowsHide: true,
|
|
112
114
|
});
|
|
113
|
-
} catch
|
|
114
|
-
throw safeCommandFailure('GIT_CONFIG_FAILED',
|
|
115
|
+
} catch {
|
|
116
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', message);
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
function readGithubAccount(projectRoot, options = {}) {
|
|
121
|
+
const values = readLocalValues(projectRoot, ACCOUNT_KEY, options);
|
|
122
|
+
if (values.length === 0) return null;
|
|
123
|
+
if (values.length !== 1) {
|
|
124
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'The clone-local github.account value must be unique.');
|
|
125
|
+
}
|
|
126
|
+
const value = values[0].trim();
|
|
127
|
+
return value || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeGithubAccount(projectRoot, login, options = {}) {
|
|
131
|
+
if (!validateGithubLogin(login)) {
|
|
132
|
+
throw safeCommandFailure('INVALID_GITHUB_LOGIN', 'GitHub login must be 1-39 letters, numbers, or internal hyphens.');
|
|
133
|
+
}
|
|
134
|
+
runGitConfig(projectRoot, ['--replace-all', ACCOUNT_KEY, login], options,
|
|
135
|
+
'Unable to write the clone-local GitHub account binding.');
|
|
136
|
+
}
|
|
137
|
+
|
|
118
138
|
function unsetGithubAccount(projectRoot, options = {}) {
|
|
119
139
|
try {
|
|
120
140
|
runnerFor(options)('git', ['config', '--local', '--unset-all', ACCOUNT_KEY], {
|
|
@@ -129,6 +149,91 @@ function unsetGithubAccount(projectRoot, options = {}) {
|
|
|
129
149
|
}
|
|
130
150
|
}
|
|
131
151
|
|
|
152
|
+
function readGithubAuto(projectRoot, options = {}) {
|
|
153
|
+
const values = readLocalValues(projectRoot, AUTO_KEY, options);
|
|
154
|
+
if (values.length === 0) return false;
|
|
155
|
+
if (values.length !== 1) return null;
|
|
156
|
+
const value = values[0].toLowerCase();
|
|
157
|
+
if (value === 'true') return true;
|
|
158
|
+
if (value === 'false') return false;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ownsCredentialHelper(value, options) {
|
|
163
|
+
return (options.isOwnedCredentialHelper || isOwnedCredentialHelperValue)(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasGithubAutoCredentialHelper(projectRoot, options = {}) {
|
|
167
|
+
const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
|
|
168
|
+
return helpers.length === 2 && helpers[0] === ''
|
|
169
|
+
&& (options.isOwnedCredentialHelper || isUsableCredentialHelperValue)(helpers[1], options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isForgeCredentialHelper(value, options) {
|
|
173
|
+
return ownsCredentialHelper(value, options)
|
|
174
|
+
|| (options.isManagedCredentialHelper || isManagedCredentialHelperValue)(value, options);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function restoreLocalValues(projectRoot, snapshots, options) {
|
|
178
|
+
for (const [key, values] of snapshots) {
|
|
179
|
+
try {
|
|
180
|
+
unsetLocalKey(projectRoot, key, options);
|
|
181
|
+
for (const value of values) runnerFor(options)('git', ['config', '--local', '--add', key, value], { cwd: projectRoot, stdio: 'ignore' });
|
|
182
|
+
} catch { /* best effort rollback */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function unsetLocalKey(projectRoot, key, options) {
|
|
187
|
+
try {
|
|
188
|
+
runnerFor(options)('git', ['config', '--local', '--unset-all', key], {
|
|
189
|
+
cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.status === 5 || error?.status === 1 || /key does not contain|not found|no such key/i.test(errorText(error))) return;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function assertGithubAutoAvailable(projectRoot, options = {}) {
|
|
198
|
+
const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
|
|
199
|
+
const replaceable = helpers.length === 2 && helpers[0] === ''
|
|
200
|
+
&& isForgeCredentialHelper(helpers[1], options);
|
|
201
|
+
if (helpers.length && !replaceable) {
|
|
202
|
+
throw safeCommandFailure('GIT_CREDENTIAL_HELPER_CONFLICT', 'A clone-local GitHub credential helper is already configured.');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function enableGithubAuto(projectRoot, options = {}) {
|
|
207
|
+
assertGithubAutoAvailable(projectRoot, options);
|
|
208
|
+
const helperValue = options.credentialHelperValue;
|
|
209
|
+
if (!ownsCredentialHelper(helperValue || '', options)) {
|
|
210
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Forge did not provide a valid credential helper command.');
|
|
211
|
+
}
|
|
212
|
+
const previousHelpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
|
|
213
|
+
const previousAuto = readLocalValues(projectRoot, AUTO_KEY, options);
|
|
214
|
+
try {
|
|
215
|
+
runGitConfig(projectRoot, ['--replace-all', CREDENTIAL_HELPER_KEY, ''], options, 'Unable to enable automatic GitHub routing.');
|
|
216
|
+
runGitConfig(projectRoot, ['--add', CREDENTIAL_HELPER_KEY, helperValue], options, 'Unable to enable automatic GitHub routing.');
|
|
217
|
+
runGitConfig(projectRoot, ['--replace-all', AUTO_KEY, 'true'], options, 'Unable to enable automatic GitHub routing.');
|
|
218
|
+
} catch (error) {
|
|
219
|
+
restoreLocalValues(projectRoot, [[CREDENTIAL_HELPER_KEY, previousHelpers], [AUTO_KEY, previousAuto]], options);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function disableGithubAuto(projectRoot, options = {}) {
|
|
225
|
+
const helpers = readLocalValues(projectRoot, CREDENTIAL_HELPER_KEY, options);
|
|
226
|
+
const previousAuto = readLocalValues(projectRoot, AUTO_KEY, options);
|
|
227
|
+
const owned = helpers.length === 2 && helpers[0] === '' && isForgeCredentialHelper(helpers[1], options);
|
|
228
|
+
const keys = [AUTO_KEY, ...(owned ? [CREDENTIAL_HELPER_KEY] : [])];
|
|
229
|
+
try {
|
|
230
|
+
for (const key of keys) unsetLocalKey(projectRoot, key, options);
|
|
231
|
+
} catch {
|
|
232
|
+
restoreLocalValues(projectRoot, [[AUTO_KEY, previousAuto], [CREDENTIAL_HELPER_KEY, helpers]], options);
|
|
233
|
+
throw safeCommandFailure('GIT_CONFIG_FAILED', 'Unable to disable automatic GitHub routing.');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
132
237
|
function withoutGithubEnvironment(environment) {
|
|
133
238
|
const result = {};
|
|
134
239
|
for (const [key, value] of Object.entries(environment || {})) {
|
|
@@ -177,6 +282,20 @@ function retrieveGithubToken(login, options) {
|
|
|
177
282
|
return token;
|
|
178
283
|
}
|
|
179
284
|
|
|
285
|
+
function createGithubTokenContext(projectRoot, options = {}) {
|
|
286
|
+
const account = (options.readAccount || readGithubAccount)(projectRoot, options);
|
|
287
|
+
if (!validateGithubLogin(account)) {
|
|
288
|
+
throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'No selected GitHub account is available.');
|
|
289
|
+
}
|
|
290
|
+
const baseEnv = { ...(options.baseEnv || options.env || process.env) };
|
|
291
|
+
const token = retrieveGithubToken(account, { ...options, baseEnv });
|
|
292
|
+
return createPublicContext(publicStatus('selected', account, null), token, baseEnv, options);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function writeGithubCredential(projectRoot, write, options = {}) {
|
|
296
|
+
createGithubTokenContext(projectRoot, options).writeCredential(write);
|
|
297
|
+
}
|
|
298
|
+
|
|
180
299
|
function loginFromOutput(output) {
|
|
181
300
|
const text = output == null ? '' : output.toString().trim();
|
|
182
301
|
if (!text) return null;
|
|
@@ -270,9 +389,16 @@ function createPublicContext(status, token, baseEnv, options) {
|
|
|
270
389
|
throw safeGithubCommandFailure(error);
|
|
271
390
|
}
|
|
272
391
|
};
|
|
392
|
+
const writeCredential = (write) => {
|
|
393
|
+
if (status.state === 'unbound' || typeof write !== 'function') {
|
|
394
|
+
throw safeCommandFailure('GITHUB_NOT_AUTHENTICATED', 'No selected GitHub account is available.');
|
|
395
|
+
}
|
|
396
|
+
write(`username=${status.account}\npassword=${token}\n\n`);
|
|
397
|
+
};
|
|
273
398
|
Object.defineProperties(context, {
|
|
274
399
|
runChild: { value: runChild, enumerable: false },
|
|
275
400
|
runGh: { value: runGh, enumerable: false },
|
|
401
|
+
writeCredential: { value: writeCredential, enumerable: false },
|
|
276
402
|
getStatus: { value: () => ({ ...status }), enumerable: false },
|
|
277
403
|
});
|
|
278
404
|
return context;
|
|
@@ -296,12 +422,21 @@ function createGithubContext(projectRoot, options = {}) {
|
|
|
296
422
|
|
|
297
423
|
module.exports = {
|
|
298
424
|
ACCOUNT_KEY,
|
|
425
|
+
AUTO_KEY,
|
|
426
|
+
CREDENTIAL_HELPER_KEY,
|
|
299
427
|
GithubContextError,
|
|
428
|
+
assertGithubAutoAvailable,
|
|
300
429
|
createGithubContext,
|
|
430
|
+
createGithubTokenContext,
|
|
431
|
+
disableGithubAuto,
|
|
432
|
+
enableGithubAuto,
|
|
433
|
+
hasGithubAutoCredentialHelper,
|
|
301
434
|
prepareGithubAccount,
|
|
302
435
|
prepareGithubContext: createGithubContext,
|
|
303
436
|
readGithubAccount,
|
|
437
|
+
readGithubAuto,
|
|
304
438
|
resolveGithubLogin,
|
|
439
|
+
writeGithubCredential,
|
|
305
440
|
unsetGithubAccount,
|
|
306
441
|
validateGithubLogin,
|
|
307
442
|
writeGithubAccount,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readGithubAuto, writeGithubCredential } = require('./github-context');
|
|
4
|
+
const { assertGithubRouterCloneRegistered } = require('./github-router');
|
|
5
|
+
|
|
6
|
+
function parseRequest(input) {
|
|
7
|
+
if (typeof input !== 'string' || input.length > 64 * 1024) return null;
|
|
8
|
+
const request = Object.create(null);
|
|
9
|
+
for (const line of input.split(/\r?\n/)) {
|
|
10
|
+
if (!line) break;
|
|
11
|
+
const separator = line.indexOf('=');
|
|
12
|
+
if (separator < 1) return null;
|
|
13
|
+
request[line.slice(0, separator)] = line.slice(separator + 1);
|
|
14
|
+
}
|
|
15
|
+
return request;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runCredentialHelper(operation, options = {}) {
|
|
19
|
+
if (!['get', 'store', 'erase'].includes(operation)) return 1;
|
|
20
|
+
if (operation !== 'get') return 0;
|
|
21
|
+
const request = parseRequest(options.input || '');
|
|
22
|
+
const host = request?.host?.toLowerCase();
|
|
23
|
+
if (request?.protocol !== 'https' || (host !== 'github.com' && host !== 'github.com:443')) return 0;
|
|
24
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
25
|
+
const write = options.write || (value => process.stdout.write(value));
|
|
26
|
+
const writeError = options.writeError || (value => process.stderr.write(value));
|
|
27
|
+
try {
|
|
28
|
+
const automatic = (options.readAuto || readGithubAuto)(projectRoot, options);
|
|
29
|
+
if (automatic === false) return 0;
|
|
30
|
+
if (automatic !== true) throw new Error('Automatic GitHub routing configuration is invalid.');
|
|
31
|
+
if (!options.createContext || options.assertRegistered) {
|
|
32
|
+
(options.assertRegistered || assertGithubRouterCloneRegistered)(projectRoot, options);
|
|
33
|
+
}
|
|
34
|
+
if (options.createContext) {
|
|
35
|
+
options.createContext(projectRoot, options).writeCredential(write);
|
|
36
|
+
} else {
|
|
37
|
+
writeGithubCredential(projectRoot, write, options);
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
} catch {
|
|
41
|
+
write('quit=true\n\n');
|
|
42
|
+
writeError('Forge could not resolve this clone\'s GitHub account. Run forge github status.\n');
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { parseRequest, runCredentialHelper };
|