gowalk-cicd 1.0.142 → 1.0.144
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/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/docs/CONFIGURATION.md +16 -6
- package/package.json +1 -1
- package/scripts/lib/checks.mjs +2 -2
- package/scripts/lib/coordination.mjs +39 -0
- package/scripts/upgrade-org.mjs +4 -34
- package/src/config/validation-events.mjs +56 -0
- package/src/config/validation.mjs +9 -20
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.144
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.144
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.144
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -45,9 +45,12 @@ inferred safe from a custom step name. Candidate misses never wait or reserve a
|
|
|
45
45
|
|
|
46
46
|
Separate custom workflows remain app-owned. Recognized native dependency jobs
|
|
47
47
|
receive optional lock/toolchain-keyed cache restore/save inside their existing job.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
Supported validation workflows use their existing PR trigger for `task/**` branches,
|
|
49
|
+
keeping default-branch push checks and all job names, commands and custom checks.
|
|
50
|
+
This requires a single app-owned validation workflow, a PR trigger covering the
|
|
51
|
+
known default branch, and jobs without event-dependent behavior or custom GitHub scripts. Other workflows retain separate push/PR concurrency
|
|
52
|
+
groups, so neither required check cancels the other. An upgrade also repairs the shared
|
|
53
|
+
group emitted by 1.0.142–143. Separate platforms and repositories keep their existing parallelism.
|
|
51
54
|
|
|
52
55
|
`.gowalk-cicd.lock.json` records the template and generated-file digests. Commit it
|
|
53
56
|
with the configuration and rendered workflows. It is migration state, not a CI job.
|
|
@@ -62,7 +65,7 @@ in the same repository; unrelated repositories can continue upgrading.
|
|
|
62
65
|
|
|
63
66
|
# Organization rollout
|
|
64
67
|
|
|
65
|
-
Use
|
|
68
|
+
Use a released organization script and the reviewed installer package:
|
|
66
69
|
|
|
67
70
|
```sh
|
|
68
71
|
node /path/to/package/scripts/upgrade-org.mjs \
|
|
@@ -75,8 +78,10 @@ It paginates the organization, discovers actual installed actions, and uses one
|
|
|
75
78
|
independent repositories concurrently. Existing CI remains authoritative; each
|
|
76
79
|
successful candidate merges independently, and any resulting push workflows are
|
|
77
80
|
followed before branch/checkout cleanup. No factory task or Mac reservation is
|
|
78
|
-
created. API throttling and
|
|
79
|
-
|
|
81
|
+
created. API throttling and `--max-active` bound publication. One repository’s queued CI
|
|
82
|
+
never stops unrelated PRs; GitHub schedules each platform against its available runners.
|
|
83
|
+
Existing checks must finish successfully, and GitHub enforces its required checks and reviews
|
|
84
|
+
for the exact candidate. A repository without candidate checks gains no new CI requirement.
|
|
80
85
|
|
|
81
86
|
The state file records repository heads, PR/run URLs, failures and cleanup.
|
|
82
87
|
Restart with the same file and package to resume. `--retry-unresolved` retries
|
|
@@ -84,3 +89,8 @@ recorded exceptions after their cause is repaired; `--dry-run` prepares local
|
|
|
84
89
|
changes without pushing, opening PRs or merging. Use a separate state file for a
|
|
85
90
|
new target version. The final status counts distinguish updated, already-current,
|
|
86
91
|
not-installed and unresolved repositories; an unresolved result exits nonzero.
|
|
92
|
+
|
|
93
|
+
`--installer-dir /path/to/reviewed/package` selects a retained installer independently of
|
|
94
|
+
the organization script release. Its package version owns the existing state, branches and
|
|
95
|
+
installed files. This lets an orchestration-only repair resume a rollout without reinstalling
|
|
96
|
+
another package version or replacing its PRs. Omitting it installs the script’s own package.
|
package/package.json
CHANGED
package/scripts/lib/checks.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export async function checkCandidate(github, repo, options) {
|
|
|
10
10
|
const runs = await github.pages(`repos/${repo.name}/actions/runs?head_sha=${repo.head}`, 'workflow_runs');
|
|
11
11
|
const latest = new Map();
|
|
12
12
|
for (const run of runs.filter(run => ['push', 'pull_request'].includes(run.event))) {
|
|
13
|
-
const key = run.workflow_id
|
|
13
|
+
const key = `${run.workflow_id}:${run.event}`;
|
|
14
14
|
if (!latest.has(key) || latest.get(key).id < run.id) latest.set(key, run);
|
|
15
15
|
}
|
|
16
16
|
const current = [...latest.values()];
|
|
@@ -22,13 +22,13 @@ export async function checkCandidate(github, repo, options) {
|
|
|
22
22
|
repo.nextAt = Date.now() + 30000;
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
-
if (!current.length) throw new RolloutError('candidate_ci_missing');
|
|
26
25
|
if (current.some(run => !['success', 'skipped'].includes(run.conclusion))) {
|
|
27
26
|
throw new RolloutError('candidate_ci_failed');
|
|
28
27
|
}
|
|
29
28
|
// GitHub enforces existing required checks and reviews; no admin bypass or new gate is installed.
|
|
30
29
|
await (github.command || command)('gh', ['pr', 'merge', String(repo.pr), '--repo', repo.name, '--squash',
|
|
31
30
|
'--match-head-commit', repo.head]);
|
|
31
|
+
repo.validation = current.length ? 'existing_ci' : 'github_merge_policy';
|
|
32
32
|
repo.status = 'deploy';
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
|
package/scripts/upgrade-org.mjs
CHANGED
|
@@ -1,44 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
import {
|
|
4
|
-
import { resolve, dirname, join } from 'node:path';
|
|
2
|
+
// Use a released coordinator and reviewed installer. Workers never claim or reserve factory Macs.
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { GitHub, RolloutError, delay } from './lib/github.mjs';
|
|
7
6
|
import { openState, summary } from './lib/state.mjs';
|
|
7
|
+
import { options, eligible } from './lib/coordination.mjs';
|
|
8
8
|
import { discover, inspect } from './lib/discovery.mjs';
|
|
9
9
|
import { prepare, publish, incorporateBase } from './lib/repository.mjs';
|
|
10
10
|
import { checkCandidate, checkDeploy, cleanup } from './lib/checks.mjs';
|
|
11
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
12
|
async function advance(github, repo, opts) {
|
|
43
13
|
const handlers = { inspect, prepare, publish, ci: checkCandidate, deploy: checkDeploy, cleanup };
|
|
44
14
|
if (repo.status === 'update-base') return incorporateBase(repo);
|
|
@@ -99,7 +69,7 @@ async function run(opts, state) {
|
|
|
99
69
|
|
|
100
70
|
let state;
|
|
101
71
|
try {
|
|
102
|
-
const opts = options(process.argv.slice(2));
|
|
72
|
+
const opts = options(process.argv.slice(2), resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
103
73
|
state = openState(opts.statePath, opts.org, opts.version);
|
|
104
74
|
await run(opts, state);
|
|
105
75
|
state.save();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Keep required push/PR checks independent; avoid duplicate factory pushes only for supported PR validation.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
const REF = '${{ github.ref }}';
|
|
4
|
+
const COALESCED = '${{ github.event.pull_request.head.repo.full_name == github.repository'
|
|
5
|
+
+ ' && github.event.pull_request.head.ref || github.ref_name }}';
|
|
6
|
+
|
|
7
|
+
export function defaultBranch(root) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
|
10
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
11
|
+
.trim().replace(/^refs\/remotes\/origin\//, '');
|
|
12
|
+
} catch { return ''; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function coversCandidates(event, branch, jobs) {
|
|
16
|
+
if (!branch || (event !== null && (typeof event !== 'object' || Array.isArray(event)))) return false;
|
|
17
|
+
const filters = event || {};
|
|
18
|
+
if (Object.keys(filters).some(key => !['branches', 'types'].includes(key))) return false;
|
|
19
|
+
if (filters.types && (!Array.isArray(filters.types)
|
|
20
|
+
|| !['opened', 'synchronize', 'reopened'].every(type => filters.types.includes(type)))) return false;
|
|
21
|
+
if (filters.branches && (!Array.isArray(filters.branches) || !filters.branches.includes(branch)
|
|
22
|
+
|| filters.branches.some(value => typeof value !== 'string' || /[!*?\[\]+]/.test(value)))) return false;
|
|
23
|
+
// Event-dependent app commands and job conditions need their own reviewed migration.
|
|
24
|
+
return !/github\.(event|ref)|GITHUB_(EVENT|REF)|actions\/github-script@/.test(JSON.stringify(jobs));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function excludeFactoryPush(doc, push) {
|
|
28
|
+
if (push !== null && (typeof push !== 'object' || Array.isArray(push))) return false;
|
|
29
|
+
const filters = push || {};
|
|
30
|
+
const field = Object.hasOwn(filters, 'branches') ? 'branches' : 'branches-ignore';
|
|
31
|
+
if (!Object.hasOwn(filters, field) && ('tags' in filters || 'tags-ignore' in filters)) return false;
|
|
32
|
+
const values = filters[field] || [];
|
|
33
|
+
if (!Array.isArray(values) || values.some(value => typeof value !== 'string')) return false;
|
|
34
|
+
if ('branches' in filters && 'branches-ignore' in filters) return false;
|
|
35
|
+
const exclusion = field === 'branches' ? '!task/**' : 'task/**';
|
|
36
|
+
if (values.at(-1) === exclusion) return false;
|
|
37
|
+
doc.setIn(['on', 'push'], doc.createNode({ ...filters, [field]: [...values, exclusion] }));
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function upgradeEvents(doc, value, branch) {
|
|
42
|
+
const events = value.on;
|
|
43
|
+
const concurrency = value.concurrency;
|
|
44
|
+
if (!events || !Object.hasOwn(events, 'push') || !Object.hasOwn(events, 'pull_request')
|
|
45
|
+
|| concurrency?.['cancel-in-progress'] !== true || typeof concurrency.group !== 'string') return false;
|
|
46
|
+
const original = concurrency.group;
|
|
47
|
+
const group = original.replace(COALESCED, REF);
|
|
48
|
+
const recognized = group.includes('${{ github.workflow }}') || group === `flutter-ci-${REF}`;
|
|
49
|
+
if (!group.includes(REF) || !recognized) return false;
|
|
50
|
+
let changed = group !== original;
|
|
51
|
+
if (changed) doc.setIn(['concurrency', 'group'], group);
|
|
52
|
+
if (coversCandidates(events.pull_request, branch, value.jobs)) {
|
|
53
|
+
changed = excludeFactoryPush(doc, events.push) || changed;
|
|
54
|
+
}
|
|
55
|
+
return changed;
|
|
56
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Upgrade supported app-owned validation in place; preserve jobs,
|
|
1
|
+
// Upgrade supported app-owned validation in place; preserve jobs, checks and custom commands.
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readYaml, MigrationRepair } from './yaml.mjs';
|
|
5
5
|
import { upgradeReuse } from './validation-reuse.mjs';
|
|
6
|
+
import { defaultBranch, upgradeEvents } from './validation-events.mjs';
|
|
6
7
|
const CACHE = 'caa296126883cff596d87d8935842f9db880ef25';
|
|
7
8
|
const PREFIX = '.github/actions/swift-app/scripts/';
|
|
8
9
|
|
|
@@ -48,32 +49,20 @@ function upgradeCaches(doc, value, file) {
|
|
|
48
49
|
return changed;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function coalesceValidation(doc, value) {
|
|
52
|
-
const events = value.on;
|
|
53
|
-
const concurrency = value.concurrency;
|
|
54
|
-
if (!events || !Object.hasOwn(events, 'push') || !Object.hasOwn(events, 'pull_request')
|
|
55
|
-
|| concurrency?.['cancel-in-progress'] !== true || typeof concurrency.group !== 'string') return false;
|
|
56
|
-
const group = concurrency.group;
|
|
57
|
-
if (!group.includes('${{ github.ref }}')) return false;
|
|
58
|
-
if (!group.includes('${{ github.workflow }}') && group !== 'flutter-ci-${{ github.ref }}') return false;
|
|
59
|
-
const identity = '${{ github.event.pull_request.head.repo.full_name == github.repository'
|
|
60
|
-
+ ' && github.event.pull_request.head.ref || github.ref_name }}';
|
|
61
|
-
doc.setIn(['concurrency', 'group'], group.replace('${{ github.ref }}', identity));
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
52
|
export function planValidation(root, managed) {
|
|
66
53
|
const directory = join(root, '.github/workflows');
|
|
67
54
|
if (!existsSync(directory)) return [];
|
|
68
55
|
const writes = [];
|
|
69
|
-
|
|
56
|
+
const files = readdirSync(directory).filter(file => /\.ya?ml$/.test(file) && !managed.includes(file));
|
|
57
|
+
// Another app-owned workflow may depend on this workflow's push evidence.
|
|
58
|
+
const branch = files.length === 1 ? defaultBranch(root) : '';
|
|
59
|
+
for (const file of files) {
|
|
70
60
|
const source = readFileSync(join(directory, file), 'utf8');
|
|
71
61
|
const { doc, value } = readYaml(source, file);
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const reused = compatibleGroup && upgradeReuse(doc, value, file);
|
|
62
|
+
const events = upgradeEvents(doc, value, branch);
|
|
63
|
+
const reused = upgradeReuse(doc, value, file);
|
|
75
64
|
const cached = upgradeCaches(doc, doc.toJS({ maxAliasCount: 100 }), file);
|
|
76
|
-
if (cached ||
|
|
65
|
+
if (cached || events || reused) {
|
|
77
66
|
writes.push({ file: `.github/workflows/${file}`, content: doc.toString({ lineWidth: 120 }) });
|
|
78
67
|
}
|
|
79
68
|
}
|