Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions actions/org_sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ The organization list is a plain JSON array of names, matching the existing `Liz

```json
[
"KokoStreamTV",
"LizardByte",
"LizardByte-infrastructure",
"SunshineStream",
"Themerr",
"KokoStreamTV"
"Themerr"
]
```

Expand Down Expand Up @@ -92,7 +92,7 @@ a rule to some of these names; it does not replace or change the format of `orga
},
{
"id": "organization-pr-template",
"repositories": ["SunshineStream/.github", "Themerr/.github", "KokoStreamTV/.github"],
"repository_names": [".github"],
"files": [
{"source": ".github/pull_request_template.md"}
]
Expand All @@ -111,6 +111,10 @@ a rule to some of these names; it does not replace or change the format of `orga
- Each rule has a unique `id`, an optional `profile`, and a nonempty `files` array.
- A rule applies to all listed orgs by default. Narrow it with either `organizations` or exact `repositories`.
These selectors must stay inside `organizations.json`.
- `repository_names` matches exact repo names, case-insensitively, within the configured organizations. For
example, `[".github"]` follows additions to `organizations.json` automatically and skips organizations that
do not yet contain that repo. It can be combined with `organizations`, but not with exact `repositories`.
Name selectors still respect repository eligibility and custom properties. Globs are not supported.
- The `repository` action input narrows the policy to one exact repo without listing org repositories.
- `profile` matches membership in the property selected by `workflow_profiles_property` (default
`workflow_profiles`). Discovery considers inactive profile rules too, so deselection is reconciled on the next run.
Expand Down Expand Up @@ -173,11 +177,21 @@ access is also reported without blocking other orgs. API retries are handled by

## 📤 Outputs

Every invocation writes a run summary and uploads a JSON report artifact with a unique `file-sync-` name,
including dry-run/delivery mode, source revision, scope, counts, and per-target results. Reports are retained
for 14 days. Partial results and fatal sync failures are reported too; a report does not turn a failed sync
into success. Multiple invocations in the same job receive separate report files/artifacts.

Reports use the already redacted `results` output, so private target details remain omitted. No consumer-side
report script or upload step is needed. Creating reports/artifacts does not write to target repositories.

| Output | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| `results` | JSON array of repository statuses, changes, warnings, errors, and PR URLs where applicable. |
| `changed_repositories` | Count of updated/closed PRs or repositories with planned changes. |
| `failed_repositories` | Count of failed targets. |
| `report_file` | Runner-local path to the complete JSON report. |
| `artifact_name` | Unique uploaded report artifact name. |

Schedule full runs to catch property changes; property edits do not change source files. Before replacing an
existing replicator, migrate exclusions/profiles, compare dry-run results, and disable its writer.
34 changes: 34 additions & 0 deletions actions/org_sync/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ outputs:
results:
description: 'JSON array of per-repository results and planned file changes. Private target details are omitted.'
value: ${{ steps.sync.outputs.results }}
report_file:
description: 'Runner-local path to the JSON report, including source, mode, scope, and per-target results.'
value: ${{ steps.report.outputs.report_file }}
artifact_name:
description: 'Unique name of the JSON report artifact uploaded by this invocation.'
value: ${{ steps.report.outputs.artifact_name }}

runs:
using: "composite"
Expand All @@ -91,3 +97,31 @@ runs:
script: |
const script = require('${{ github.action_path }}/org_sync.js');
await script({ github, context, core });

- name: Save synchronization report
id: report
if: >-
always() && !cancelled() &&
(steps.sync.outcome == 'success' || steps.sync.outcome == 'failure')
env:
SYNC_DRY_RUN: ${{ inputs.dry_run }}
SYNC_RESULTS: ${{ steps.sync.outputs.results }}
SYNC_OUTCOME: ${{ steps.sync.outcome }}
SYNC_CHANGED: ${{ steps.sync.outputs.changed_repositories }}
SYNC_FAILED: ${{ steps.sync.outputs.failed_repositories }}
SYNC_ORGANIZATION: ${{ inputs.organization }}
SYNC_REPOSITORY: ${{ inputs.repository }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const writeSyncReport = require('${{ github.action_path }}/report.js');
await writeSyncReport({ core, context });

- name: Upload synchronization report
if: always() && !cancelled() && steps.report.outcome == 'success'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ steps.report.outputs.artifact_name }}
path: ${{ steps.report.outputs.report_file }}
if-no-files-found: error
retention-days: 14
12 changes: 9 additions & 3 deletions actions/org_sync/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,15 @@ function loadConfig({ sourceDirectory, configFile, organizationsFile }) {
assert(rule.profile === undefined || (typeof rule.profile === 'string' && /^[\w-]+$/.test(rule.profile)),
'Invalid workflow profile');
assert(!(rule.organizations && rule.repositories), 'A rule cannot specify both organizations and repositories');
assert(!(rule.repositories && rule.repository_names), 'A rule cannot specify both repositories and repository_names');
const orgs = rule.organizations === undefined ? organizations : stringList(rule.organizations,
'rule organizations', item => NAME.test(item) && orgNames.has(item.toLowerCase()));
const repos = rule.repositories === undefined ? null : stringList(rule.repositories, 'rule repositories',
item => REPOSITORY.test(item) && orgNames.has(item.split('/')[0].toLowerCase()));
const names = rule.repository_names === undefined ? null : stringList(rule.repository_names, 'repository names',
item => /^[\w.-]+$/.test(item) && !['.', '..'].includes(item));
assert(orgs.length > 0 && (repos === null || repos.length > 0), 'Rules must have targets');
assert(names === null || names.length > 0, 'Repository names must have targets');
assert(Array.isArray(rule.files) && rule.files.length > 0, 'Each rule needs files');
const files = rule.files.map(file => {
assert(file && typeof file === 'object' && !Array.isArray(file), 'Invalid file mapping');
Expand All @@ -93,15 +97,17 @@ function loadConfig({ sourceDirectory, configFile, organizationsFile }) {
}
return { destination, mode, source, ...sources.get(source), permissions: file.permissions ?? sources.get(source).permissions };
});
return { id: rule.id, profile: rule.profile, organizations: orgs, repositories: repos, files };
return { id: rule.id, profile: rule.profile, organizations: orgs, repositories: repos, repository_names: names, files };
});
return { id, organizations, rules, ...filters };
}

function matches(rule, fullName) {
const normalized = fullName.toLowerCase();
return rule.repositories ? rule.repositories.some(repo => repo.toLowerCase() === normalized)
: rule.organizations.some(org => org.toLowerCase() === normalized.split('/')[0]);
const [owner, name] = normalized.split('/');
const inScope = rule.repositories ? rule.repositories.some(repo => repo.toLowerCase() === normalized)
: rule.organizations.some(org => org.toLowerCase() === owner);
return inScope && (!rule.repository_names || rule.repository_names.some(repo => repo.toLowerCase() === name));
}

function properties(values, { syncModeProperty = 'sync_mode', workflowProfilesProperty = 'workflow_profiles' } = {}) {
Expand Down
58 changes: 58 additions & 0 deletions actions/org_sync/report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const { mkdir, mkdtemp, writeFile } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');

/**
* Save preview or delivery results and publish a summary for any action consumer.
* Failed sync steps can still provide partial results for the other organizations.
*
* @param {object} options Actions core/context, step environment, and optional output directory.
* @returns {Promise<object>} The report written to the artifact.
*/
async function writeSyncReport({ core, context, env = process.env, outputDirectory }) {
if (!['true', 'false'].includes(env.SYNC_DRY_RUN)) {
throw new TypeError('Expected an explicit file sync dry-run mode');
}
const results = JSON.parse(env.SYNC_RESULTS || '[]');
if (!Array.isArray(results)) {
throw new TypeError('Expected an array of file sync results');
}
const report = {
dry_run: env.SYNC_DRY_RUN === 'true',
source_commit: context.sha,
source_ref: context.ref,
organization: env.SYNC_ORGANIZATION || null,
repository: env.SYNC_REPOSITORY || null,
outcome: env.SYNC_OUTCOME,
changed_repositories: Number(env.SYNC_CHANGED || 0),
failed_repositories: Number(env.SYNC_FAILED || 0),
results,
};
const directory = outputDirectory ?? await mkdtemp(path.join(os.tmpdir(), 'org-sync-report-'));
await mkdir(directory, { recursive: true });
const reportFile = path.join(directory, 'results.json');
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
const artifactName = `file-sync-${path.basename(directory)}`;
core.setOutput('report_file', reportFile);
core.setOutput('artifact_name', artifactName);
core.summary.addHeading(report.dry_run ? 'File sync preview' : 'File synchronization');
core.summary.addRaw(report.dry_run
? 'Dry run only. No target Git objects, branches, or pull requests were written.\n\n'
: 'Delivery enabled. Shared-file changes are proposed through target pull requests.\n\n');
core.summary.addRaw(`Sync step outcome: **${report.outcome}**. See the step logs for failures.\n\n`);
core.summary.addRaw(`Repositories changed or planned: **${report.changed_repositories}**; `
+ `failed targets: **${report.failed_repositories}**.\n\n`);
core.summary.addTable([
[
{ data: 'Repository', header: true },
{ data: 'Status', header: true },
{ data: 'File changes', header: true },
],
...results.map(result => [result.repository, result.status, String(result.changes?.length ?? 0)]),
]);
core.summary.addRaw(`\nDownload the ${artifactName} artifact for exact paths, operations, warnings, errors, and PR URLs.\n`);
await core.summary.write();
return report;
}

module.exports = writeSyncReport;
48 changes: 48 additions & 0 deletions tests/org_sync/org_sync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,51 @@ describe('scope and operational behavior', () => {
expect(f.github.paginate).not.toHaveBeenCalled();
});

test('repository-name rules automatically follow the organization list and skip absent repos', async () => {
policy([{ id: 'pr-template', repository_names: ['.GitHub'], files: [mapping()] }]);
f.write('organizations.json', JSON.stringify(['org', 'other', 'new-org', 'empty-org']));
f.add('org/.github');
f.add('other/.github');
f.add('new-org/.github');
f.add('org/app');
f.add('org/.github-extra');
const result = await f.run({ INPUT_DRY_RUN: 'true' });
expect(result.map(item => item.repository)).toEqual(['new-org/.github', 'org/.github', 'other/.github']);
expect(f.core.setFailed).not.toHaveBeenCalled();
expect(f.writes()).toHaveLength(0);
});

test('name selectors combine with organization scope and exact-repository input', async () => {
policy([{ id: 'pr-template', organizations: ['org'], repository_names: ['.github'], files: [mapping()] }]);
f.add('org/.github');
f.add('other/.github');
f.add('org/app');
expect((await f.run({ INPUT_DRY_RUN: 'true' })).map(item => item.repository)).toEqual(['org/.github']);
expect((await f.run({ INPUT_DRY_RUN: 'true', INPUT_ORGANIZATION: 'other' }))).toEqual([]);
f.github.paginate.mockClear();
expect((await f.run({ INPUT_DRY_RUN: 'true', INPUT_REPOSITORY: 'org/.github' }))[0].status).toBe('planned');
expect(f.github.paginate).not.toHaveBeenCalled();
expect(await f.run({ INPUT_DRY_RUN: 'true', INPUT_REPOSITORY: 'org/app' })).toEqual([]);
expect(f.core.setFailed).toHaveBeenLastCalledWith('Requested repository is outside the configured policy');
});

test('general rules and name-specific templates remain separate during delivery', async () => {
policy([
{ id: 'general', files: [mapping({ destination: 'shared.yml' })] },
{ id: 'pr-template', repository_names: ['.github'], files: [mapping({ destination: 'template.md' })] },
]);
const defaults = f.add('org/.github');
const project = f.add('org/app');
defaults.syncMode = 'excluded';
expect((await f.run()).find(item => item.repository === 'org/.github').status).toBe('excluded');
defaults.syncMode = 'included';
await f.run();
expect(f.content(defaults, 'template.md')).toBe('shared workflow\n');
expect(f.content(defaults, 'shared.yml')).toBe('shared workflow\n');
expect(f.content(project, 'shared.yml')).toBe('shared workflow\n');
expect(f.content(project, 'template.md')).toBeNull();
});

test('narrows by organization and respects per-rule organization selectors', async () => {
policy([{ id: 'one-org', organizations: ['org'], files: [mapping()] }]);
f.add('org/app');
Expand Down Expand Up @@ -530,6 +575,9 @@ describe('configuration validation', () => {
{ version: 1, rules: [{ id: 'x', profile: 'bad profile', files: [mapping()] }] },
{ version: 1, rules: [{ id: 'x', repositories: ['elsewhere/app'], files: [mapping()] }] },
{ version: 1, rules: [{ id: 'x', repositories: [], files: [mapping()] }] },
...[[], ['.'], ['..'], ['*'], ['org/.github'], ['.github', '.GitHub'], 'not-an-array'].map(repository_names =>
({ version: 1, rules: [{ id: 'x', repository_names, files: [mapping()] }] })),
{ version: 1, rules: [{ id: 'x', repositories: ['org/app'], repository_names: ['app'], files: [mapping()] }] },
{ version: 1, rules: [{ id: 'x', repositories: ['org/app'], organizations: ['org'], files: [mapping()] }] },
{ version: 1, rules: [{ id: 'x', files: [null] }] },
{ version: 1, rules: [{ id: 'x', files: [mapping({ mode: 'unexpected' })] }] },
Expand Down
102 changes: 102 additions & 0 deletions tests/org_sync/report.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const writeSyncReport = require('../../actions/org_sync/report.js');

let directory;
let outputs;
let core;
const context = { sha: 'source-revision', ref: 'refs/pull/1/merge' };

beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'org-sync-report-test-'));
outputs = {};
core = {
setOutput: jest.fn((name, value) => { outputs[name] = value; }),
summary: { addHeading: jest.fn(), addRaw: jest.fn(), addTable: jest.fn(), write: jest.fn() },
};
});

afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
jest.restoreAllMocks();
});

test('preview reports preserve partial failures and their exact changes in the artifact', async () => {
const results = [
{ repository: 'org/app', status: 'planned', changes: [{ path: 'old.yml', operation: 'remove' }], warnings: [] },
{ repository: 'other/*', status: 'failed', error: 'Property access denied' },
{ repository: 'org/mirror', status: 'excluded' },
];
const report = await writeSyncReport({ core, context, outputDirectory: directory, env: {
SYNC_DRY_RUN: 'true', SYNC_RESULTS: JSON.stringify(results), SYNC_OUTCOME: 'failure',
SYNC_CHANGED: '1', SYNC_FAILED: '1', SYNC_ORGANIZATION: 'org', SYNC_REPOSITORY: 'org/app',
} });
expect(report).toEqual({ dry_run: true, source_commit: 'source-revision', source_ref: 'refs/pull/1/merge',
organization: 'org', repository: 'org/app', outcome: 'failure', changed_repositories: 1,
failed_repositories: 1, results });
expect(JSON.parse(await fs.readFile(outputs.report_file, 'utf8'))).toEqual(report);
expect(outputs.artifact_name).toBe(`file-sync-${path.basename(directory)}`);
expect(core.summary.addHeading).toHaveBeenCalledWith('File sync preview');
expect(core.summary.addTable.mock.calls[0][0].slice(1)).toEqual([
['org/app', 'planned', '1'], ['other/*', 'failed', '0'], ['org/mirror', 'excluded', '0'],
]);
expect(core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining('Dry run only'));
expect(core.summary.write).toHaveBeenCalledTimes(1);
});

test('delivery reports retain target PR URLs and mark their mode accurately', async () => {
const results = [{ repository: 'org/app', status: 'updated', changes: [],
pull_request: 'https://github.com/org/app/pull/42' }];
const report = await writeSyncReport({ core, context, outputDirectory: directory,
env: { SYNC_DRY_RUN: 'false', SYNC_RESULTS: JSON.stringify(results), SYNC_OUTCOME: 'success' } });
expect(report.dry_run).toBe(false);
expect(report.results).toEqual(results);
expect(core.summary.addHeading).toHaveBeenCalledWith('File synchronization');
const prose = core.summary.addRaw.mock.calls.flat().join('');
expect(prose).toContain('Delivery enabled');
expect(prose).not.toMatch(/Dry run only|No target/);
});

test('a fatal sync without outputs still produces a delivery diagnostic', async () => {
const report = await writeSyncReport({ core, context, outputDirectory: directory,
env: { SYNC_DRY_RUN: 'false', SYNC_OUTCOME: 'failure' } });
expect(report.results).toEqual([]);
expect(report.changed_repositories).toBe(0);
expect(report.failed_repositories).toBe(0);
expect(report.organization).toBeNull();
expect(report.repository).toBeNull();
expect(core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining('**failure**'));
});

test('repeated invocations allocate separate report directories and artifact names', async () => {
jest.spyOn(os, 'tmpdir').mockReturnValue(directory);
const options = { core, context, env: { SYNC_DRY_RUN: 'true' } };
await writeSyncReport(options);
const first = { ...outputs };
await writeSyncReport(options);
expect(outputs.report_file).not.toBe(first.report_file);
expect(outputs.artifact_name).not.toBe(first.artifact_name);
expect(JSON.parse(await fs.readFile(first.report_file, 'utf8')).dry_run).toBe(true);
expect(JSON.parse(await fs.readFile(outputs.report_file, 'utf8')).dry_run).toBe(true);
});

test.each([undefined, '', 'FALSE', '0'])('rejects invalid mode %s before publishing a report', async mode => {
await expect(writeSyncReport({ core, context, env: { SYNC_DRY_RUN: mode } }))
.rejects.toThrow('Expected an explicit file sync dry-run mode');
expect(core.setOutput).not.toHaveBeenCalled();
expect(core.summary.write).not.toHaveBeenCalled();
});

test.each(['{}', '{invalid-json'])('rejects malformed output %s before publishing a report', async results => {
await expect(writeSyncReport({ core, context, env: { SYNC_DRY_RUN: 'true', SYNC_RESULTS: results } }))
.rejects.toThrow();
expect(core.setOutput).not.toHaveBeenCalled();
expect(core.summary.write).not.toHaveBeenCalled();
});

test('uses the composite action environment when no explicit environment is passed', async () => {
jest.replaceProperty(process, 'env', { ...process.env, SYNC_DRY_RUN: 'true' });
const report = await writeSyncReport({ core, context, outputDirectory: directory });
expect(report.dry_run).toBe(true);
});
Loading