gowalk-cicd 1.0.166 → 1.0.167
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/android-action/scripts/gradle_wrapper.py +2 -1
- package/android-action/scripts/test_gradle_wrapper.py +7 -0
- package/backend-action/test_network_docker.py +1 -1
- package/bin/cli.mjs +0 -16
- package/docs/CONFIGURATION.md +0 -32
- package/package.json +2 -8
- package/action/.daemux-version +0 -1
- package/android-action/.daemux-version +0 -1
- package/backend-action/.daemux-version +0 -1
- package/scripts/lib/checks.mjs +0 -73
- package/scripts/lib/coordination.mjs +0 -39
- package/scripts/lib/discovery.mjs +0 -27
- package/scripts/lib/github.mjs +0 -85
- package/scripts/lib/repository.mjs +0 -82
- package/scripts/lib/state.mjs +0 -39
- package/scripts/upgrade-org.mjs +0 -81
|
@@ -87,13 +87,14 @@ def _exists(name: str) -> bool | None:
|
|
|
87
87
|
return None
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def normalize(version: Version, probe=
|
|
90
|
+
def normalize(version: Version, probe=None) -> str:
|
|
91
91
|
"""The distribution name to write into distributionUrl.
|
|
92
92
|
|
|
93
93
|
Probes services.gradle.org so a future naming change corrects itself; falls
|
|
94
94
|
back to the ordering in `candidates` when the network is unavailable, which
|
|
95
95
|
is no worse than not probing at all.
|
|
96
96
|
"""
|
|
97
|
+
probe = probe or _exists
|
|
97
98
|
options = candidates(version)
|
|
98
99
|
for name in options:
|
|
99
100
|
if probe(name) is True:
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import tempfile
|
|
7
7
|
import unittest
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
9
10
|
|
|
10
11
|
import gradle_wrapper as gw
|
|
11
12
|
|
|
@@ -29,6 +30,12 @@ def never(_name: str) -> bool:
|
|
|
29
30
|
return False
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def setUpModule():
|
|
34
|
+
patcher = mock.patch.object(gw, "_exists", never)
|
|
35
|
+
patcher.start()
|
|
36
|
+
unittest.addModuleCleanup(patcher.stop)
|
|
37
|
+
|
|
38
|
+
|
|
32
39
|
class RenderTest(unittest.TestCase):
|
|
33
40
|
def test_eight_x_drops_a_trailing_zero_patch(self):
|
|
34
41
|
self.assertEqual(gw.candidates((8, 14, 0))[0], "8.14")
|
|
@@ -60,7 +60,7 @@ def cleanup(projects):
|
|
|
60
60
|
or observed['Name'] != app + '_default'):
|
|
61
61
|
raise AssertionError('Owned test cleanup scope changed')
|
|
62
62
|
print(json.dumps(fixture_ipam_evidence(observed)), flush=True)
|
|
63
|
-
docker('compose', '-f', str(path), '--project-name', app, 'down', '--remove-orphans')
|
|
63
|
+
docker('compose', '-f', str(path), '--project-name', app, 'down', '--remove-orphans', '-t', '0')
|
|
64
64
|
remaining = docker('network', 'ls', '--filter', 'label=com.docker.compose.project=' + app, '-q')
|
|
65
65
|
if remaining:
|
|
66
66
|
raise AssertionError('Owned test network remains')
|
package/bin/cli.mjs
CHANGED
|
@@ -7,19 +7,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
7
7
|
const __dirname = dirname(__filename);
|
|
8
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
9
|
|
|
10
|
-
// The update nag is a nicety, not a dependency of installing. Running the CLI
|
|
11
|
-
// straight out of a source checkout (`node ../gowalk-cicd/bin/cli.mjs`, the
|
|
12
|
-
// documented local-development path) has no node_modules, and a hard import
|
|
13
|
-
// would take the whole installer down with it.
|
|
14
|
-
async function loadNotifier() {
|
|
15
|
-
try {
|
|
16
|
-
const { default: updateNotifier } = await import('update-notifier');
|
|
17
|
-
return updateNotifier({ pkg });
|
|
18
|
-
} catch {
|
|
19
|
-
return { notify() {} };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
10
|
const args = process.argv.slice(2);
|
|
24
11
|
if (args[0] === 'release') {
|
|
25
12
|
const platform = args[1] || 'both';
|
|
@@ -96,6 +83,3 @@ try {
|
|
|
96
83
|
}));
|
|
97
84
|
process.exit(1);
|
|
98
85
|
}
|
|
99
|
-
|
|
100
|
-
const notifier = await loadNotifier();
|
|
101
|
-
notifier.notify();
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -95,35 +95,3 @@ An unknown layout or overlapping custom code edit returns a JSON
|
|
|
95
95
|
`gowalk-cicd/migration-repair.v1` receipt with its code, file and YAML path. The
|
|
96
96
|
receipt never includes the conflicting value. Resolve that specific customization
|
|
97
97
|
in the same repository; unrelated repositories can continue upgrading.
|
|
98
|
-
|
|
99
|
-
# Organization rollout
|
|
100
|
-
|
|
101
|
-
Use a released organization script and the reviewed installer package:
|
|
102
|
-
|
|
103
|
-
```sh
|
|
104
|
-
node /path/to/package/scripts/upgrade-org.mjs \
|
|
105
|
-
--org gowalk-public --state /durable/cicd-rollout/state.json \
|
|
106
|
-
--workers 8 --max-active 8
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
It paginates the organization, discovers actual installed actions, and uses one
|
|
110
|
-
`task/gowalk-cicd-<version>` branch/PR per affected repository. Workers prepare
|
|
111
|
-
independent repositories concurrently. Existing CI remains authoritative; each
|
|
112
|
-
successful candidate merges independently, and any resulting push workflows are
|
|
113
|
-
followed before branch/checkout cleanup. No factory task or Mac reservation is
|
|
114
|
-
created. API throttling and `--max-active` bound publication. One repository’s queued CI
|
|
115
|
-
never stops unrelated PRs; GitHub schedules each platform against its available runners.
|
|
116
|
-
Existing checks must finish successfully, and GitHub enforces its required checks and reviews
|
|
117
|
-
for the exact candidate. A repository without candidate checks gains no new CI requirement.
|
|
118
|
-
|
|
119
|
-
The state file records repository heads, PR/run URLs, failures and cleanup.
|
|
120
|
-
Restart with the same file and package to resume. `--retry-unresolved` retries
|
|
121
|
-
recorded exceptions after their cause is repaired; `--dry-run` prepares local
|
|
122
|
-
changes without pushing, opening PRs or merging. Use a separate state file for a
|
|
123
|
-
new target version. The final status counts distinguish updated, already-current,
|
|
124
|
-
not-installed and unresolved repositories; an unresolved result exits nonzero.
|
|
125
|
-
|
|
126
|
-
`--installer-dir /path/to/reviewed/package` selects a retained installer independently of
|
|
127
|
-
the organization script release. Its package version owns the existing state, branches and
|
|
128
|
-
installed files. This lets an orchestration-only repair resume a rollout without reinstalling
|
|
129
|
-
another package version or replacing its PRs. Omitting it installs the script’s own package.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.167",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,8 +25,6 @@
|
|
|
25
25
|
"docs/CANDIDATE_ARTIFACTS.md",
|
|
26
26
|
"docs/BACKEND_EDGE.md",
|
|
27
27
|
"docs/BACKEND_NETWORKS.md",
|
|
28
|
-
"scripts/upgrade-org.mjs",
|
|
29
|
-
"scripts/lib/",
|
|
30
28
|
"docs/CONFIGURATION.md"
|
|
31
29
|
],
|
|
32
30
|
"keywords": [
|
|
@@ -56,13 +54,9 @@
|
|
|
56
54
|
"node": ">=18"
|
|
57
55
|
},
|
|
58
56
|
"scripts": {
|
|
59
|
-
"test": "
|
|
60
|
-
"test:installer": "node --test test/*.test.mjs",
|
|
61
|
-
"test:android": "python3 -m unittest discover -s android-action/scripts -p 'test_*.py'",
|
|
62
|
-
"test:ios": "python3 -m unittest discover -s action/scripts -p 'test_*.py'"
|
|
57
|
+
"test": "node scripts/test.mjs"
|
|
63
58
|
},
|
|
64
59
|
"dependencies": {
|
|
65
|
-
"update-notifier": "^7.3.1",
|
|
66
60
|
"yaml": "2.9.0"
|
|
67
61
|
}
|
|
68
62
|
}
|
package/action/.daemux-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.0.166
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.0.166
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.0.166
|
package/scripts/lib/checks.mjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { command, RolloutError } from './github.mjs';
|
|
2
|
-
import { rmSync } from 'node:fs';
|
|
3
|
-
|
|
4
|
-
export async function checkCandidate(github, repo, options) {
|
|
5
|
-
const pull = await github.api(`repos/${repo.name}/pulls/${repo.pr}`);
|
|
6
|
-
if (pull.merged) { repo.merge = pull.merge_commit_sha; repo.status = 'deploy'; return; }
|
|
7
|
-
if (pull.state !== 'open') throw new RolloutError('migration_pull_request_closed');
|
|
8
|
-
if (pull.head.sha !== repo.head) throw new RolloutError('migration_pull_request_head_changed');
|
|
9
|
-
if (pull.mergeable === false) { repo.status = 'update-base'; return; }
|
|
10
|
-
const runs = await github.pages(`repos/${repo.name}/actions/runs?head_sha=${repo.head}`, 'workflow_runs');
|
|
11
|
-
const latest = new Map();
|
|
12
|
-
for (const run of runs.filter(run => ['push', 'pull_request'].includes(run.event))) {
|
|
13
|
-
const key = `${run.workflow_id}:${run.event}`;
|
|
14
|
-
if (!latest.has(key) || latest.get(key).id < run.id) latest.set(key, run);
|
|
15
|
-
}
|
|
16
|
-
const current = [...latest.values()];
|
|
17
|
-
repo.queued = current.filter(run => ['queued', 'waiting', 'pending'].includes(run.status)).length;
|
|
18
|
-
repo.runs = current.map(run => ({ id: run.id, status: run.status, conclusion: run.conclusion, url: run.html_url }));
|
|
19
|
-
const enrolling = !current.length && Date.now() - repo.publishedAt < 60000;
|
|
20
|
-
if (enrolling || current.some(run => run.status !== 'completed') || pull.mergeable == null) {
|
|
21
|
-
if (Date.now() - repo.publishedAt > options.ciTimeout) throw new RolloutError('candidate_ci_timeout');
|
|
22
|
-
repo.nextAt = Date.now() + 30000;
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
if (current.some(run => !['success', 'skipped'].includes(run.conclusion))) {
|
|
26
|
-
throw new RolloutError('candidate_ci_failed');
|
|
27
|
-
}
|
|
28
|
-
// GitHub enforces existing required checks and reviews; no admin bypass or new gate is installed.
|
|
29
|
-
await (github.command || command)('gh', ['pr', 'merge', String(repo.pr), '--repo', repo.name, '--squash',
|
|
30
|
-
'--match-head-commit', repo.head]);
|
|
31
|
-
repo.validation = current.length ? 'existing_ci' : 'github_merge_policy';
|
|
32
|
-
repo.status = 'deploy';
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function checkDeploy(github, repo, options) {
|
|
36
|
-
const pull = await github.api(`repos/${repo.name}/pulls/${repo.pr}`);
|
|
37
|
-
if (!pull.merged || !pull.merge_commit_sha) throw new RolloutError('merge_readback_failed');
|
|
38
|
-
repo.merge = pull.merge_commit_sha;
|
|
39
|
-
repo.mergedAt ||= Date.parse(pull.merged_at);
|
|
40
|
-
const runs = await github.pages(`repos/${repo.name}/actions/runs?head_sha=${repo.merge}`, 'workflow_runs');
|
|
41
|
-
const current = runs.filter(run => run.event === 'push');
|
|
42
|
-
repo.deploys = current.map(run => ({
|
|
43
|
-
id: run.id, status: run.status, conclusion: run.conclusion, url: run.html_url,
|
|
44
|
-
}));
|
|
45
|
-
if (Date.now() - repo.mergedAt < 60000 || current.some(run => run.status !== 'completed')) {
|
|
46
|
-
if (Date.now() - repo.mergedAt > options.ciTimeout) throw new RolloutError('deployment_ci_timeout');
|
|
47
|
-
repo.nextAt = Date.now() + 30000;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (current.some(run => !['success', 'skipped'].includes(run.conclusion))) {
|
|
51
|
-
throw new RolloutError('deployment_ci_failed');
|
|
52
|
-
}
|
|
53
|
-
repo.status = 'cleanup';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function cleanup(github, repo) {
|
|
57
|
-
const reference = await github.api(`repos/${repo.name}/git/ref/heads/${repo.branch}`, { missing: true });
|
|
58
|
-
if (reference && reference.object.sha !== repo.head) throw new RolloutError('cleanup_branch_head_changed');
|
|
59
|
-
if (reference) await github.api(`repos/${repo.name}/git/refs/heads/${repo.branch}`, { method: 'DELETE' });
|
|
60
|
-
await command('git', ['switch', '--detach', repo.merge], { cwd: repo.directory }).catch(async () => {
|
|
61
|
-
await command('git', ['fetch', 'origin', repo.defaultBranch], { cwd: repo.directory });
|
|
62
|
-
await command('git', ['switch', '--detach', repo.merge], { cwd: repo.directory });
|
|
63
|
-
});
|
|
64
|
-
const local = await command('git', ['branch', '--list', repo.branch], { cwd: repo.directory });
|
|
65
|
-
if (local.trim()) await command('git', ['update-ref', '-d', `refs/heads/${repo.branch}`, repo.head], {
|
|
66
|
-
cwd: repo.directory,
|
|
67
|
-
});
|
|
68
|
-
const dirty = (await command('git', ['status', '--porcelain'], { cwd: repo.directory })).trim();
|
|
69
|
-
if (dirty) throw new RolloutError('cleanup_checkout_dirty');
|
|
70
|
-
rmSync(repo.directory, { recursive: true });
|
|
71
|
-
repo.status = 'updated';
|
|
72
|
-
repo.cleaned = true;
|
|
73
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { resolve, dirname, join } from 'node:path';
|
|
3
|
-
import { RolloutError } from './github.mjs';
|
|
4
|
-
|
|
5
|
-
export function options(args, packageDir) {
|
|
6
|
-
const result = { packageDir, org: 'gowalk-public', workers: 8, maxActive: 8, ciTimeout: 3600000 };
|
|
7
|
-
const names = { '--org': 'org', '--state': 'statePath', '--workers': 'workers',
|
|
8
|
-
'--max-active': 'maxActive', '--installer-dir': 'packageDir' };
|
|
9
|
-
for (let index = 0; index < args.length; index++) {
|
|
10
|
-
if (args[index] === '--dry-run') { result.dryRun = true; continue; }
|
|
11
|
-
if (args[index] === '--retry-unresolved') { result.retryUnresolved = true; continue; }
|
|
12
|
-
const name = names[args[index]];
|
|
13
|
-
if (!name || !args[index + 1]) throw new RolloutError('invalid_rollout_arguments');
|
|
14
|
-
result[name] = ['workers', 'maxActive'].includes(name) ? Number(args[++index]) : args[++index];
|
|
15
|
-
}
|
|
16
|
-
if (!result.statePath || !/^[\w-]+$/.test(result.org)
|
|
17
|
-
|| ![result.workers, result.maxActive].every(value => Number.isInteger(value) && value > 0 && value <= 32)) {
|
|
18
|
-
throw new RolloutError('invalid_rollout_arguments');
|
|
19
|
-
}
|
|
20
|
-
result.packageDir = resolve(result.packageDir);
|
|
21
|
-
const target = JSON.parse(readFileSync(join(result.packageDir, 'package.json'), 'utf8'));
|
|
22
|
-
if (target.name !== 'gowalk-cicd' || !/^\d+\.\d+\.\d+$/.test(target.version)) {
|
|
23
|
-
throw new RolloutError('invalid_installer_package');
|
|
24
|
-
}
|
|
25
|
-
result.version = target.version;
|
|
26
|
-
result.statePath = resolve(result.statePath);
|
|
27
|
-
result.workDir = join(dirname(result.statePath), 'checkouts');
|
|
28
|
-
return result;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function eligible(repo, repositories, opts) {
|
|
32
|
-
if ((repo.nextAt || 0) > Date.now()) return false;
|
|
33
|
-
if (repo.status === 'publish') {
|
|
34
|
-
const active = repositories.filter(row => ['ci', 'deploy', 'cleanup'].includes(row.status));
|
|
35
|
-
return active.length + (opts.publishing || 0) < opts.maxActive;
|
|
36
|
-
}
|
|
37
|
-
return ['inspect', 'prepare', 'ci', 'deploy', 'cleanup', 'update-base'].includes(repo.status);
|
|
38
|
-
}
|
|
39
|
-
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { RolloutError } from './github.mjs';
|
|
2
|
-
const installs = /^\.github\/actions\/(swift-app|android-app|backend-app)\/(action\.yml|\.daemux-version)$/;
|
|
3
|
-
|
|
4
|
-
export async function discover(github, state) {
|
|
5
|
-
const repositories = await github.pages(`orgs/${state.data.org}/repos?type=all`);
|
|
6
|
-
for (const repo of repositories) {
|
|
7
|
-
if (state.data.repositories[repo.full_name]) continue;
|
|
8
|
-
state.data.repositories[repo.full_name] = {
|
|
9
|
-
name: repo.full_name, defaultBranch: repo.default_branch, archived: repo.archived,
|
|
10
|
-
disabled: repo.disabled, writable: repo.permissions?.push === true,
|
|
11
|
-
status: repo.size ? 'inspect' : 'not-installed',
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
state.data.discovered = true;
|
|
15
|
-
state.save();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function inspect(github, repo) {
|
|
19
|
-
const tree = await github.api(`repos/${repo.name}/git/trees/${encodeURIComponent(repo.defaultBranch)}?recursive=1`);
|
|
20
|
-
if (tree.truncated) throw new RolloutError('repository_tree_truncated');
|
|
21
|
-
const installed = tree.tree.some(file => installs.test(file.path));
|
|
22
|
-
if (!installed) { repo.status = 'not-installed'; return; }
|
|
23
|
-
repo.installed = true;
|
|
24
|
-
repo.status = repo.archived || repo.disabled || !repo.writable ? 'unresolved' : 'prepare';
|
|
25
|
-
if (repo.status === 'unresolved') repo.reason = 'repository_not_writable';
|
|
26
|
-
repo.tree = tree.sha;
|
|
27
|
-
}
|
package/scripts/lib/github.mjs
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// The installed gh credential remains inside gh. Results and failures never include raw CLI stderr.
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { promisify } from 'node:util';
|
|
4
|
-
const execute = promisify(execFile);
|
|
5
|
-
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
-
export class RolloutError extends Error {
|
|
7
|
-
constructor(code, retryAt = null) { super(code); this.code = code; this.retryAt = retryAt; }
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function command(program, args, options = {}) {
|
|
11
|
-
try {
|
|
12
|
-
return (await execute(program, args, {
|
|
13
|
-
encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 180000, ...options,
|
|
14
|
-
})).stdout;
|
|
15
|
-
} catch (error) {
|
|
16
|
-
const failure = new RolloutError(error.killed ? 'command_timeout' : `${program}_command_failed`);
|
|
17
|
-
const line = String(error.stderr || '').trim().split('\n').at(-1);
|
|
18
|
-
try {
|
|
19
|
-
const receipt = JSON.parse(line);
|
|
20
|
-
if (receipt.schema === 'gowalk-cicd/migration-repair.v1' && /^[a-z_]+$/.test(receipt.code)) {
|
|
21
|
-
failure.code = receipt.code;
|
|
22
|
-
failure.result = receipt;
|
|
23
|
-
}
|
|
24
|
-
} catch { /* Unknown output remains private. */ }
|
|
25
|
-
throw failure;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class GitHub {
|
|
30
|
-
constructor() { this.blockedUntil = 0; this.nextRequest = 0; }
|
|
31
|
-
|
|
32
|
-
async api(endpoint, { method = 'GET', data, missing = false } = {}) {
|
|
33
|
-
const now = Date.now();
|
|
34
|
-
if (this.blockedUntil > now) throw new RolloutError('github_rate_limited', this.blockedUntil);
|
|
35
|
-
const start = Math.max(now, this.nextRequest);
|
|
36
|
-
this.nextRequest = start + 150;
|
|
37
|
-
await delay(start - now);
|
|
38
|
-
const args = ['api', '--include', '--method', method, endpoint];
|
|
39
|
-
if (data) args.push('--input', '-');
|
|
40
|
-
const result = await new Promise(resolve => {
|
|
41
|
-
const child = execFile('gh', args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60000 },
|
|
42
|
-
(error, stdout) => resolve({ error, stdout }));
|
|
43
|
-
child.stdin.end(data ? JSON.stringify(data) : undefined);
|
|
44
|
-
});
|
|
45
|
-
const response = decodeResponse(result.stdout);
|
|
46
|
-
if (response.status === 404 && missing) return null;
|
|
47
|
-
const retry = Number(response.headers['retry-after']);
|
|
48
|
-
const remaining = Number(response.headers['x-ratelimit-remaining'] ?? 5000);
|
|
49
|
-
if (response.status === 429 || (response.status === 403 && (retry || remaining === 0))) {
|
|
50
|
-
this.blockedUntil = retry ? Date.now() + retry * 1000
|
|
51
|
-
: Number(response.headers['x-ratelimit-reset']) * 1000 + 5000 || Date.now() + 60000;
|
|
52
|
-
throw new RolloutError('github_rate_limited', this.blockedUntil);
|
|
53
|
-
}
|
|
54
|
-
if (result.error || response.status >= 400) {
|
|
55
|
-
throw new RolloutError(`github_http_${response.status || 'unavailable'}`,
|
|
56
|
-
response.status >= 500 ? Date.now() + 30000 : null);
|
|
57
|
-
}
|
|
58
|
-
return response.body;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async pages(endpoint, key = null) {
|
|
62
|
-
const rows = [];
|
|
63
|
-
for (let page = 1; ; page++) {
|
|
64
|
-
const result = await this.api(`${endpoint}${endpoint.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
|
|
65
|
-
const batch = key ? result[key] : result;
|
|
66
|
-
if (!Array.isArray(batch)) throw new RolloutError('github_pagination_shape_invalid');
|
|
67
|
-
rows.push(...batch);
|
|
68
|
-
if (batch.length < 100) return rows;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function decodeResponse(text) {
|
|
74
|
-
const split = text.search(/\r?\n\r?\n/);
|
|
75
|
-
const head = split < 0 ? '' : text.slice(0, split);
|
|
76
|
-
const lines = head.split(/\r?\n/);
|
|
77
|
-
const status = Number(lines.shift()?.match(/HTTP\/\S+\s+(\d+)/)?.[1] || 0);
|
|
78
|
-
const headers = Object.fromEntries(lines.flatMap(line => {
|
|
79
|
-
const colon = line.indexOf(':');
|
|
80
|
-
return colon < 0 ? [] : [[line.slice(0, colon).toLowerCase(), line.slice(colon + 1).trim()]];
|
|
81
|
-
}));
|
|
82
|
-
let body;
|
|
83
|
-
try { body = JSON.parse(text.slice(split).trim()); } catch { body = null; }
|
|
84
|
-
return { status, headers, body };
|
|
85
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { command, RolloutError } from './github.mjs';
|
|
4
|
-
const git = (cwd, ...args) => command('git', args, { cwd });
|
|
5
|
-
|
|
6
|
-
export async function checkout(repo, options) {
|
|
7
|
-
const directory = join(options.workDir, repo.name.split('/')[1]);
|
|
8
|
-
repo.directory = directory;
|
|
9
|
-
repo.branch ||= `task/gowalk-cicd-${options.version}`;
|
|
10
|
-
if (!existsSync(directory)) {
|
|
11
|
-
mkdirSync(options.workDir, { recursive: true });
|
|
12
|
-
await command('git', ['clone', '--filter=blob:none', '--no-checkout',
|
|
13
|
-
`https://github.com/${repo.name}.git`, directory]);
|
|
14
|
-
}
|
|
15
|
-
const origin = (await git(directory, 'remote', 'get-url', 'origin')).trim();
|
|
16
|
-
if (!origin.endsWith(`${repo.name}.git`)) throw new RolloutError('checkout_repository_mismatch');
|
|
17
|
-
await git(directory, 'fetch', '--prune', 'origin', repo.defaultBranch);
|
|
18
|
-
await git(directory, 'sparse-checkout', 'set', '--cone', '.github', 'backend', 'server', 'web');
|
|
19
|
-
const local = (await git(directory, 'branch', '--list', repo.branch)).trim();
|
|
20
|
-
const remote = (await git(directory, 'ls-remote', '--heads', 'origin', repo.branch)).trim();
|
|
21
|
-
if (local) await git(directory, 'switch', repo.branch);
|
|
22
|
-
else if (remote) {
|
|
23
|
-
await git(directory, 'fetch', 'origin', repo.branch);
|
|
24
|
-
await git(directory, 'switch', '-c', repo.branch, 'FETCH_HEAD');
|
|
25
|
-
} else await git(directory, 'switch', '-c', repo.branch, `origin/${repo.defaultBranch}`);
|
|
26
|
-
repo.base = (await git(directory, 'rev-parse', `origin/${repo.defaultBranch}`)).trim();
|
|
27
|
-
return directory;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function prepare(github, repo, options) {
|
|
31
|
-
const directory = await checkout(repo, options);
|
|
32
|
-
const before = (await git(directory, 'status', '--porcelain')).trim();
|
|
33
|
-
if (before && !repo.preparing) throw new RolloutError('checkout_has_unowned_changes');
|
|
34
|
-
repo.preparing = true;
|
|
35
|
-
options.save();
|
|
36
|
-
await command(process.execPath, [join(options.packageDir, 'bin/cli.mjs')], { cwd: directory });
|
|
37
|
-
const diff = (await git(directory, 'diff', '--name-only')).trim();
|
|
38
|
-
const untracked = (await git(directory, 'ls-files', '--others', '--exclude-standard')).trim();
|
|
39
|
-
const changed = [...new Set((diff + '\n' + untracked).split('\n').filter(Boolean))];
|
|
40
|
-
if (changed.some(file => !file.startsWith('.github/') && !/^\.gowalk-cicd\.(yml|lock\.json)$/.test(file))) {
|
|
41
|
-
throw new RolloutError('migration_changed_unowned_file');
|
|
42
|
-
}
|
|
43
|
-
if (changed.length) {
|
|
44
|
-
await git(directory, 'add', '--', ...changed);
|
|
45
|
-
await git(directory, 'diff', '--cached', '--check');
|
|
46
|
-
await git(directory, 'commit', '-m', `Upgrade gowalk-cicd to ${options.version}`,
|
|
47
|
-
'-m', 'Preserve app settings in configuration and adopt supported workflow migrations.');
|
|
48
|
-
}
|
|
49
|
-
repo.head = (await git(directory, 'rev-parse', 'HEAD')).trim();
|
|
50
|
-
repo.preparing = false;
|
|
51
|
-
const ahead = (await git(directory, 'rev-list', '--count', `origin/${repo.defaultBranch}..HEAD`)).trim();
|
|
52
|
-
repo.status = ahead === '0' ? 'already-current' : 'publish';
|
|
53
|
-
if (options.dryRun) repo.status = ahead === '0' ? 'already-current' : 'dry-run';
|
|
54
|
-
if (repo.status === 'already-current') {
|
|
55
|
-
rmSync(directory, { recursive: true });
|
|
56
|
-
repo.cleaned = true;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function publish(github, repo) {
|
|
61
|
-
await git(repo.directory, 'push', '--set-upstream', 'origin', repo.branch);
|
|
62
|
-
const pulls = await github.api(`repos/${repo.name}/pulls?state=all&head=${repo.name.split('/')[0]}:${repo.branch}`);
|
|
63
|
-
let pull = pulls.find(pr => pr.state === 'open');
|
|
64
|
-
if (!pull) pull = await github.api(`repos/${repo.name}/pulls`, { method: 'POST', data: {
|
|
65
|
-
title: 'Preserve app configuration during gowalk-cicd upgrades', head: repo.branch, base: repo.defaultBranch,
|
|
66
|
-
body: 'Upgrade the installed plugin and move app settings into `.gowalk-cicd.yml`. '
|
|
67
|
-
+ 'Generated workflows retain custom checks and secret references; supported caches and candidate reuse '
|
|
68
|
-
+ 'are adopted by the installer. Existing repository CI validates this exact candidate. '
|
|
69
|
-
+ 'No factory tasks, scheduling changes, Mac reservations or additional approval stages are introduced.',
|
|
70
|
-
} });
|
|
71
|
-
repo.pr = pull.number;
|
|
72
|
-
repo.url = pull.html_url;
|
|
73
|
-
repo.publishedAt = Date.now();
|
|
74
|
-
repo.status = 'ci';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function incorporateBase(repo) {
|
|
78
|
-
await git(repo.directory, 'fetch', 'origin', repo.defaultBranch);
|
|
79
|
-
try { await git(repo.directory, 'merge', '--no-edit', `origin/${repo.defaultBranch}`); }
|
|
80
|
-
catch { throw new RolloutError('migration_branch_conflict'); }
|
|
81
|
-
repo.status = 'prepare';
|
|
82
|
-
}
|
package/scripts/lib/state.mjs
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync,
|
|
3
|
-
} from 'node:fs';
|
|
4
|
-
import { dirname } from 'node:path';
|
|
5
|
-
import { RolloutError } from './github.mjs';
|
|
6
|
-
|
|
7
|
-
export function openState(path, org, version) {
|
|
8
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
9
|
-
const lock = path + '.lock';
|
|
10
|
-
try { closeSync(openSync(lock, 'wx', 0o600)); }
|
|
11
|
-
catch {
|
|
12
|
-
let owner;
|
|
13
|
-
try { owner = JSON.parse(readFileSync(lock, 'utf8')); } catch { throw new RolloutError('rollout_lock_unreadable'); }
|
|
14
|
-
try { process.kill(owner.pid, 0); throw new RolloutError('rollout_already_running'); }
|
|
15
|
-
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
16
|
-
unlinkSync(lock);
|
|
17
|
-
closeSync(openSync(lock, 'wx', 0o600));
|
|
18
|
-
}
|
|
19
|
-
writeFileSync(lock, JSON.stringify({ pid: process.pid }));
|
|
20
|
-
const data = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {
|
|
21
|
-
schema: 1, org, version, repositories: {}, discovered: false,
|
|
22
|
-
};
|
|
23
|
-
if (data.schema !== 1 || data.org !== org || data.version !== version) {
|
|
24
|
-
unlinkSync(lock);
|
|
25
|
-
throw new RolloutError('rollout_state_target_mismatch');
|
|
26
|
-
}
|
|
27
|
-
const save = () => {
|
|
28
|
-
writeFileSync(path + '.tmp', JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
29
|
-
renameSync(path + '.tmp', path);
|
|
30
|
-
};
|
|
31
|
-
return { data, save, close: () => unlinkSync(lock) };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function summary(state) {
|
|
35
|
-
return Object.values(state.repositories).reduce((result, repo) => {
|
|
36
|
-
result[repo.status] = (result[repo.status] || 0) + 1;
|
|
37
|
-
return result;
|
|
38
|
-
}, {});
|
|
39
|
-
}
|
package/scripts/upgrade-org.mjs
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Use a released coordinator and reviewed installer. Workers never claim or reserve factory Macs.
|
|
3
|
-
import { resolve, dirname } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { GitHub, RolloutError, delay } from './lib/github.mjs';
|
|
6
|
-
import { openState, summary } from './lib/state.mjs';
|
|
7
|
-
import { options, eligible } from './lib/coordination.mjs';
|
|
8
|
-
import { discover, inspect } from './lib/discovery.mjs';
|
|
9
|
-
import { prepare, publish, incorporateBase } from './lib/repository.mjs';
|
|
10
|
-
import { checkCandidate, checkDeploy, cleanup } from './lib/checks.mjs';
|
|
11
|
-
|
|
12
|
-
async function advance(github, repo, opts) {
|
|
13
|
-
const handlers = { inspect, prepare, publish, ci: checkCandidate, deploy: checkDeploy, cleanup };
|
|
14
|
-
if (repo.status === 'update-base') return incorporateBase(repo);
|
|
15
|
-
return handlers[repo.status](github, repo, opts);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function launch(github, repo, opts, state, running, status) {
|
|
19
|
-
running.set(repo.name, advance(github, repo, opts).catch(error => {
|
|
20
|
-
repo.reason = error.code || 'rollout_operation_failed';
|
|
21
|
-
repo.repair = error.result;
|
|
22
|
-
if (error.retryAt) repo.nextAt = error.retryAt;
|
|
23
|
-
else { repo.resumeStatus = status; repo.status = 'unresolved'; }
|
|
24
|
-
}).finally(() => {
|
|
25
|
-
if (status === 'publish') opts.publishing--;
|
|
26
|
-
state.save();
|
|
27
|
-
running.delete(repo.name);
|
|
28
|
-
}));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function run(opts, state) {
|
|
32
|
-
const github = new GitHub();
|
|
33
|
-
if (!state.data.discovered) await discover(github, state);
|
|
34
|
-
const recorded = Object.values(state.data.repositories);
|
|
35
|
-
if (opts.dryRun && recorded.some(repo => ['publish', 'ci', 'deploy', 'cleanup'].includes(repo.status))) {
|
|
36
|
-
throw new RolloutError('dry_run_requires_separate_state');
|
|
37
|
-
}
|
|
38
|
-
if (!opts.dryRun) for (const repo of recorded.filter(repo => repo.status === 'dry-run')) repo.status = 'publish';
|
|
39
|
-
if (opts.retryUnresolved) {
|
|
40
|
-
for (const repo of Object.values(state.data.repositories).filter(row => row.status === 'unresolved')) {
|
|
41
|
-
repo.status = repo.resumeStatus || (repo.installed ? 'prepare' : 'inspect');
|
|
42
|
-
delete repo.nextAt;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
opts.save = state.save;
|
|
46
|
-
const running = new Map();
|
|
47
|
-
let lastReport = 0;
|
|
48
|
-
while (true) {
|
|
49
|
-
const repositories = Object.values(state.data.repositories);
|
|
50
|
-
const pending = repositories.filter(repo => eligible(repo, repositories, opts) && !running.has(repo.name));
|
|
51
|
-
pending.sort((a, b) => Number(['ci', 'deploy', 'cleanup'].includes(b.status))
|
|
52
|
-
- Number(['ci', 'deploy', 'cleanup'].includes(a.status)));
|
|
53
|
-
for (const repo of pending.slice(0, opts.workers - running.size)) {
|
|
54
|
-
if (!eligible(repo, repositories, opts)) continue;
|
|
55
|
-
const status = repo.status;
|
|
56
|
-
if (status === 'publish') opts.publishing = (opts.publishing || 0) + 1;
|
|
57
|
-
launch(github, repo, opts, state, running, status);
|
|
58
|
-
}
|
|
59
|
-
if (Date.now() - lastReport > 30000) {
|
|
60
|
-
console.log(JSON.stringify({ version: opts.version, ...summary(state.data) }));
|
|
61
|
-
lastReport = Date.now();
|
|
62
|
-
}
|
|
63
|
-
const active = repositories.some(repo => !['updated', 'already-current', 'not-installed', 'unresolved', 'dry-run']
|
|
64
|
-
.includes(repo.status));
|
|
65
|
-
if (!active && !running.size) return;
|
|
66
|
-
await Promise.race([...running.values(), delay(1000)]);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
let state;
|
|
71
|
-
try {
|
|
72
|
-
const opts = options(process.argv.slice(2), resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
73
|
-
state = openState(opts.statePath, opts.org, opts.version);
|
|
74
|
-
await run(opts, state);
|
|
75
|
-
state.save();
|
|
76
|
-
console.log(JSON.stringify({ complete: true, ...summary(state.data) }));
|
|
77
|
-
if (Object.values(state.data.repositories).some(repo => repo.status === 'unresolved')) process.exitCode = 1;
|
|
78
|
-
} catch (error) {
|
|
79
|
-
console.error(JSON.stringify({ code: error.code || 'rollout_unavailable', retryAt: error.retryAt || null }));
|
|
80
|
-
process.exitCode = 1;
|
|
81
|
-
} finally { state?.close(); }
|