gowalk-cicd 1.0.140 → 1.0.142

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/CLAUDE.md CHANGED
@@ -27,7 +27,7 @@ gowalk-cicd/ ← repo root IS the gowalk-cicd package
27
27
  │ └── scripts/*.py
28
28
  ├── bin/cli.mjs ← CLI entrypoint (parses flags, calls runInstall)
29
29
  ├── src/install.mjs ← copy logic, gitignore-check, summary print
30
- ├── templates/deploy.yml ← dual-platform workflow copied verbatim
30
+ ├── templates/deploy.yml ← managed dual-platform workflow template
31
31
  ├── templates/deploy-web.yml ← Flutter web workflow copied conditionally
32
32
  ├── backend-action/ ← backend deploy action + remote host script
33
33
  ├── templates/deploy-backend.yml ← backend workflow copied when compose exists
@@ -81,8 +81,8 @@ mkdir /tmp/scratch && cd /tmp/scratch && git init
81
81
  node /path/to/gowalk-cicd/bin/cli.mjs --dry-run
82
82
  ```
83
83
 
84
- Or install into a real app repo (overwrites both action directories and
85
- `.github/workflows/deploy.yml`):
84
+ Or install into a real app repo after `npm install` in this source checkout
85
+ (updates action directories and renders workflows from `.gowalk-cicd.yml`):
86
86
 
87
87
  ```bash
88
88
  cd ~/path/to/app-project
@@ -91,8 +91,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
91
91
 
92
92
  ## Conventions
93
93
 
94
- - Use Node's built-in `fs`/`path`/`url` modules; no runtime deps except
95
- `update-notifier` for the nag banner.
94
+ - Prefer Node built-ins. `yaml` parses and edits app configuration/workflows during installation;
95
+ `update-notifier` supplies the optional update banner. Neither adds an app CI job.
96
+ - `.gowalk-cicd.yml` owns app settings and workflow extensions. The generated workflow digests
97
+ and template baselines live in `.gowalk-cicd.lock.json`. Plan all migrations before any writes;
98
+ never overwrite a conflicting app customization. See `docs/CONFIGURATION.md`.
99
+ Refresh `templates/history.json.gz` with `node scripts/build-workflow-history.mjs` when templates change.
96
100
  - Never hardcode paths — use `fileURLToPath(import.meta.url)` and derive
97
101
  from `__dirname`.
98
102
  - Keep the CLI surface minimal: `--dry-run`, `-v`, `-h`. Resist adding flags.
@@ -103,6 +107,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
103
107
  `test/env-keys.test.mjs` scans the templates and actions. Set lower-case spellings
104
108
  or clear a lower-case bypass through `play_store_proxy.py -- <command>` (child
105
109
  process) or `play_store_proxy.py --github-env` (later steps), never through YAML.
110
+ - Composite action steps cannot use workflow-only `timeout-minutes`. Native cache restores
111
+ retain their segment timeout and the caller's job deadline; workflow-level cache steps
112
+ can retain their two-minute step limits. `test/composite-timeouts.test.mjs` guards this schema boundary.
106
113
  - Store API calls belong in GitHub Actions. Local tests may build/sign but must
107
114
  never connect to App Store Connect or Google Play.
108
115
  - Every Apple/Google store API call, OAuth exchange and binary upload requires its
package/README.md CHANGED
@@ -5,6 +5,10 @@ artifacts. It supports native Swift/SwiftUI iOS projects, native Kotlin/Gradle
5
5
  Android projects, and Flutter apps. Provision encrypted repository secrets, validate a
6
6
  task candidate, and promote its release intent. Store builds and uploads run only in GitHub Actions.
7
7
 
8
+ App-specific workflow settings live in `.gowalk-cicd.yml`. The installer migrates supported
9
+ existing settings once and regenerates managed workflows without replacing custom behavior.
10
+ See [configuration and parallel organization upgrades](docs/CONFIGURATION.md).
11
+
8
12
  ## Install
9
13
 
10
14
  From the root of your app repo:
@@ -1 +1 @@
1
- 1.0.140
1
+ 1.0.142
package/action/action.yml CHANGED
@@ -304,7 +304,6 @@ runs:
304
304
  if: ${{ steps.native_cache_scope.outputs.key != '' && inputs.candidate-binary == '' }}
305
305
  uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
306
306
  continue-on-error: true
307
- timeout-minutes: 2
308
307
  env:
309
308
  NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
310
309
  SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
@@ -366,7 +365,6 @@ runs:
366
365
  && inputs.candidate-binary == '' }}
367
366
  uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
368
367
  continue-on-error: true
369
- timeout-minutes: 2
370
368
  env:
371
369
  NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
372
370
  with:
@@ -1 +1 @@
1
- 1.0.140
1
+ 1.0.142
@@ -1 +1 @@
1
- 1.0.140
1
+ 1.0.142
package/bin/cli.mjs CHANGED
@@ -54,6 +54,8 @@ workflow into the current app project. Flutter web apps also get a release
54
54
  artifact workflow. Re-run anytime to update them.
55
55
 
56
56
  What it writes:
57
+ .gowalk-cicd.yml - app-owned settings and custom workflow extensions
58
+ .gowalk-cicd.lock.json - generated workflow and template version state
57
59
  .github/actions/swift-app/ - vendored composite action (action.yml + scripts/)
58
60
  .github/actions/android-app/ - Flutter Android build/sign action
59
61
  .github/workflows/deploy.yml - dual-platform workflow
@@ -86,7 +88,14 @@ After install:
86
88
  }
87
89
 
88
90
  const { runInstall } = await import('../src/install.mjs');
89
- await runInstall({ dryRun });
91
+ try {
92
+ await runInstall({ dryRun });
93
+ } catch (error) {
94
+ console.error(JSON.stringify(error.result || {
95
+ schema: 'gowalk-cicd/migration-repair.v1', code: 'installer_unavailable',
96
+ }));
97
+ process.exit(1);
98
+ }
90
99
 
91
100
  const notifier = await loadNotifier();
92
101
  notifier.notify();
@@ -0,0 +1,86 @@
1
+ # Configuration-preserving upgrades
2
+
3
+ Run `npx --yes gowalk-cicd@<version>` from the app repository. The first upgrade
4
+ extracts supported settings from existing workflows into `.gowalk-cicd.yml` and
5
+ renders the managed workflows in the same change. Later upgrades read that file.
6
+ The installer does this locally; app CI gains no configuration job or preflight.
7
+
8
+ ```yaml
9
+ schema: 1
10
+ mobile:
11
+ platforms: ios
12
+ candidate-artifacts: true
13
+ ios:
14
+ project: Forms.xcodeproj
15
+ workspace: Forms.xcworkspace
16
+ scheme: Forms
17
+ configuration: Release
18
+ firebase-app-id: ${{ vars.FIREBASE_APP_ID }}
19
+ backend:
20
+ host: ${{ vars.BACKEND_DEPLOY_HOST }}
21
+ runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
22
+ workflows:
23
+ deploy.yml:
24
+ overrides: []
25
+ ```
26
+
27
+ Omitted settings retain the template defaults. The installer initially extracts
28
+ existing variable expressions, so current GitHub variables remain authoritative
29
+ until the app explicitly replaces their references in this file. Keep passwords,
30
+ signing material, tokens and private keys in GitHub Secrets. Reference their names;
31
+ never put their values in this configuration.
32
+
33
+ The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
34
+ `deploy-web.yml` and `deploy-backend.yml`. App-specific values outside the named
35
+ mobile/backend inputs, and custom jobs or steps embedded in those workflows,
36
+ are retained as sparse `workflows.<file>.overrides` entries. An override identifies
37
+ a YAML path, with `value` or `remove: true`; step paths use stable IDs/names rather
38
+ than numeric positions. These extensions are app-owned configuration, including
39
+ their execution conditions, dependencies and secret references.
40
+
41
+ Candidate artifacts are enabled automatically for recognized execution. Custom deployment
42
+ commands that the installer cannot establish as candidate-safe retain the normal main
43
+ release build, with `mobile.candidate-artifacts: false`; no candidate store mutation is
44
+ inferred safe from a custom step name. Candidate misses never wait or reserve a machine.
45
+
46
+ Separate custom workflows remain app-owned. Recognized native dependency jobs
47
+ receive optional lock/toolchain-keyed cache restore/save inside their existing job.
48
+ A validation workflow already cancelling superseded work may coalesce duplicate
49
+ push/PR runs for the same app branch. Its triggers, job names, commands and custom
50
+ checks remain. Separate platforms and repositories keep their existing parallelism.
51
+
52
+ `.gowalk-cicd.lock.json` records the template and generated-file digests. Commit it
53
+ with the configuration and rendered workflows. It is migration state, not a CI job.
54
+ Edit configuration, then rerun the installer; editing a managed generated workflow
55
+ directly yields `managed_workflow_modified_use_config` without overwriting the edit.
56
+
57
+ The package carries released template history for versioned three-way migrations.
58
+ An unknown layout or overlapping custom code edit returns a JSON
59
+ `gowalk-cicd/migration-repair.v1` receipt with its code, file and YAML path. The
60
+ receipt never includes the conflicting value. Resolve that specific customization
61
+ in the same repository; unrelated repositories can continue upgrading.
62
+
63
+ # Organization rollout
64
+
65
+ Use the script from the exact released package being installed:
66
+
67
+ ```sh
68
+ node /path/to/package/scripts/upgrade-org.mjs \
69
+ --org gowalk-public --state /durable/cicd-rollout/state.json \
70
+ --workers 8 --max-active 8
71
+ ```
72
+
73
+ It paginates the organization, discovers actual installed actions, and uses one
74
+ `task/gowalk-cicd-<version>` branch/PR per affected repository. Workers prepare
75
+ independent repositories concurrently. Existing CI remains authoritative; each
76
+ successful candidate merges independently, and any resulting push workflows are
77
+ followed before branch/checkout cleanup. No factory task or Mac reservation is
78
+ created. API throttling and queued CI limit new migration PR publication while
79
+ other local preparation and completed candidates continue.
80
+
81
+ The state file records repository heads, PR/run URLs, failures and cleanup.
82
+ Restart with the same file and package to resume. `--retry-unresolved` retries
83
+ recorded exceptions after their cause is repaired; `--dry-run` prepares local
84
+ changes without pushing, opening PRs or merging. Use a separate state file for a
85
+ new target version. The final status counts distinguish updated, already-current,
86
+ not-installed and unresolved repositories; an unresolved result exits nonzero.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.140",
3
+ "version": "1.0.142",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,10 @@
22
22
  "README.md",
23
23
  "CLAUDE.md",
24
24
  "docs/GRADLE_CACHE.md",
25
- "docs/CANDIDATE_ARTIFACTS.md"
25
+ "docs/CANDIDATE_ARTIFACTS.md",
26
+ "scripts/upgrade-org.mjs",
27
+ "scripts/lib/",
28
+ "docs/CONFIGURATION.md"
26
29
  ],
27
30
  "keywords": [
28
31
  "ios",
@@ -57,6 +60,7 @@
57
60
  "test:ios": "python3 -m unittest discover -s action/scripts -p 'test_*.py'"
58
61
  },
59
62
  "dependencies": {
60
- "update-notifier": "^7.3.1"
63
+ "update-notifier": "^7.3.1",
64
+ "yaml": "2.9.0"
61
65
  }
62
66
  }
@@ -0,0 +1,73 @@
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;
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.length) throw new RolloutError('candidate_ci_missing');
26
+ if (current.some(run => !['success', 'skipped'].includes(run.conclusion))) {
27
+ throw new RolloutError('candidate_ci_failed');
28
+ }
29
+ // GitHub enforces existing required checks and reviews; no admin bypass or new gate is installed.
30
+ await (github.command || command)('gh', ['pr', 'merge', String(repo.pr), '--repo', repo.name, '--squash',
31
+ '--match-head-commit', repo.head]);
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
+ }
@@ -0,0 +1,27 @@
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
+ }
@@ -0,0 +1,85 @@
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
+ }
@@ -0,0 +1,82 @@
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
+ }
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // Run from one exact published package. Local workers never claim or reserve factory Macs.
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve, dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { GitHub, RolloutError, delay } from './lib/github.mjs';
7
+ import { openState, summary } from './lib/state.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
+ function options(args) {
13
+ const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
+ const version = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')).version;
15
+ const result = { packageDir, version, org: 'gowalk-public', workers: 8, maxActive: 8, ciTimeout: 3600000 };
16
+ const names = { '--org': 'org', '--state': 'statePath', '--workers': 'workers', '--max-active': 'maxActive' };
17
+ for (let index = 0; index < args.length; index++) {
18
+ if (args[index] === '--dry-run') { result.dryRun = true; continue; }
19
+ if (args[index] === '--retry-unresolved') { result.retryUnresolved = true; continue; }
20
+ const name = names[args[index]];
21
+ if (!name || !args[index + 1]) throw new RolloutError('invalid_rollout_arguments');
22
+ result[name] = ['workers', 'maxActive'].includes(name) ? Number(args[++index]) : args[++index];
23
+ }
24
+ if (!result.statePath || !/^[\w-]+$/.test(result.org)
25
+ || ![result.workers, result.maxActive].every(value => Number.isInteger(value) && value > 0 && value <= 32)) {
26
+ throw new RolloutError('invalid_rollout_arguments');
27
+ }
28
+ result.statePath = resolve(result.statePath);
29
+ result.workDir = join(dirname(result.statePath), 'checkouts');
30
+ return result;
31
+ }
32
+
33
+ function eligible(repo, repositories, opts) {
34
+ if ((repo.nextAt || 0) > Date.now()) return false;
35
+ if (repo.status === 'publish') {
36
+ const active = repositories.filter(row => ['ci', 'deploy', 'cleanup'].includes(row.status));
37
+ return active.length + (opts.publishing || 0) < opts.maxActive && !active.some(row => row.queued > 0);
38
+ }
39
+ return ['inspect', 'prepare', 'ci', 'deploy', 'cleanup', 'update-base'].includes(repo.status);
40
+ }
41
+
42
+ async function advance(github, repo, opts) {
43
+ const handlers = { inspect, prepare, publish, ci: checkCandidate, deploy: checkDeploy, cleanup };
44
+ if (repo.status === 'update-base') return incorporateBase(repo);
45
+ return handlers[repo.status](github, repo, opts);
46
+ }
47
+
48
+ function launch(github, repo, opts, state, running, status) {
49
+ running.set(repo.name, advance(github, repo, opts).catch(error => {
50
+ repo.reason = error.code || 'rollout_operation_failed';
51
+ repo.repair = error.result;
52
+ if (error.retryAt) repo.nextAt = error.retryAt;
53
+ else { repo.resumeStatus = status; repo.status = 'unresolved'; }
54
+ }).finally(() => {
55
+ if (status === 'publish') opts.publishing--;
56
+ state.save();
57
+ running.delete(repo.name);
58
+ }));
59
+ }
60
+
61
+ async function run(opts, state) {
62
+ const github = new GitHub();
63
+ if (!state.data.discovered) await discover(github, state);
64
+ const recorded = Object.values(state.data.repositories);
65
+ if (opts.dryRun && recorded.some(repo => ['publish', 'ci', 'deploy', 'cleanup'].includes(repo.status))) {
66
+ throw new RolloutError('dry_run_requires_separate_state');
67
+ }
68
+ if (!opts.dryRun) for (const repo of recorded.filter(repo => repo.status === 'dry-run')) repo.status = 'publish';
69
+ if (opts.retryUnresolved) {
70
+ for (const repo of Object.values(state.data.repositories).filter(row => row.status === 'unresolved')) {
71
+ repo.status = repo.resumeStatus || (repo.installed ? 'prepare' : 'inspect');
72
+ delete repo.nextAt;
73
+ }
74
+ }
75
+ opts.save = state.save;
76
+ const running = new Map();
77
+ let lastReport = 0;
78
+ while (true) {
79
+ const repositories = Object.values(state.data.repositories);
80
+ const pending = repositories.filter(repo => eligible(repo, repositories, opts) && !running.has(repo.name));
81
+ pending.sort((a, b) => Number(['ci', 'deploy', 'cleanup'].includes(b.status))
82
+ - Number(['ci', 'deploy', 'cleanup'].includes(a.status)));
83
+ for (const repo of pending.slice(0, opts.workers - running.size)) {
84
+ if (!eligible(repo, repositories, opts)) continue;
85
+ const status = repo.status;
86
+ if (status === 'publish') opts.publishing = (opts.publishing || 0) + 1;
87
+ launch(github, repo, opts, state, running, status);
88
+ }
89
+ if (Date.now() - lastReport > 30000) {
90
+ console.log(JSON.stringify({ version: opts.version, ...summary(state.data) }));
91
+ lastReport = Date.now();
92
+ }
93
+ const active = repositories.some(repo => !['updated', 'already-current', 'not-installed', 'unresolved', 'dry-run']
94
+ .includes(repo.status));
95
+ if (!active && !running.size) return;
96
+ await Promise.race([...running.values(), delay(1000)]);
97
+ }
98
+ }
99
+
100
+ let state;
101
+ try {
102
+ const opts = options(process.argv.slice(2));
103
+ state = openState(opts.statePath, opts.org, opts.version);
104
+ await run(opts, state);
105
+ state.save();
106
+ console.log(JSON.stringify({ complete: true, ...summary(state.data) }));
107
+ if (Object.values(state.data.repositories).some(repo => repo.status === 'unresolved')) process.exitCode = 1;
108
+ } catch (error) {
109
+ console.error(JSON.stringify({ code: error.code || 'rollout_unavailable', retryAt: error.retryAt || null }));
110
+ process.exitCode = 1;
111
+ } finally { state?.close(); }