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.
@@ -87,13 +87,14 @@ def _exists(name: str) -> bool | None:
87
87
  return None
88
88
 
89
89
 
90
- def normalize(version: Version, probe=_exists) -> str:
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();
@@ -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.166",
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": "npm run test:installer && npm run test:android && npm run test:ios",
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
  }
@@ -1 +0,0 @@
1
- 1.0.166
@@ -1 +0,0 @@
1
- 1.0.166
@@ -1 +0,0 @@
1
- 1.0.166
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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(); }