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 +12 -5
- package/README.md +4 -0
- package/action/.daemux-version +1 -1
- package/action/action.yml +0 -2
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/bin/cli.mjs +10 -1
- package/docs/CONFIGURATION.md +86 -0
- package/package.json +7 -3
- package/scripts/lib/checks.mjs +73 -0
- package/scripts/lib/discovery.mjs +27 -0
- package/scripts/lib/github.mjs +85 -0
- package/scripts/lib/repository.mjs +82 -0
- package/scripts/lib/state.mjs +39 -0
- package/scripts/upgrade-org.mjs +111 -0
- package/src/config/action-inputs.mjs +23 -0
- package/src/config/build-steps.mjs +18 -0
- package/src/config/candidate.mjs +52 -0
- package/src/config/merge.mjs +62 -0
- package/src/config/schema.mjs +48 -0
- package/src/config/settings.mjs +62 -0
- package/src/config/structure.mjs +75 -0
- package/src/config/transaction.mjs +61 -0
- package/src/config/validation-reuse.mjs +51 -0
- package/src/config/validation.mjs +81 -0
- package/src/config/workflows.mjs +94 -0
- package/src/config/yaml.mjs +41 -0
- package/src/install.mjs +22 -139
- package/templates/history.json.gz +0 -0
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
|
|
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
|
|
85
|
-
`.
|
|
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
|
-
-
|
|
95
|
-
`update-notifier`
|
|
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:
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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.
|
|
1
|
+
1.0.142
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
|
|
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.
|
|
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(); }
|