From c4828dc659fd8e381db3ace5766cf6470961eb85 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:05:01 -0500 Subject: [PATCH] feat(org_sync): add reports and repository name selectors --- actions/org_sync/README.md | 20 ++++++- actions/org_sync/action.yml | 34 +++++++++++ actions/org_sync/config.js | 12 +++- actions/org_sync/report.js | 58 ++++++++++++++++++ tests/org_sync/org_sync.test.js | 48 +++++++++++++++ tests/org_sync/report.test.js | 102 ++++++++++++++++++++++++++++++++ 6 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 actions/org_sync/report.js create mode 100644 tests/org_sync/report.test.js diff --git a/actions/org_sync/README.md b/actions/org_sync/README.md index 5858f22..fa75d33 100644 --- a/actions/org_sync/README.md +++ b/actions/org_sync/README.md @@ -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" ] ``` @@ -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"} ] @@ -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. @@ -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. diff --git a/actions/org_sync/action.yml b/actions/org_sync/action.yml index bc16bbd..9bb9a10 100644 --- a/actions/org_sync/action.yml +++ b/actions/org_sync/action.yml @@ -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" @@ -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 diff --git a/actions/org_sync/config.js b/actions/org_sync/config.js index c6b4dbe..273c404 100644 --- a/actions/org_sync/config.js +++ b/actions/org_sync/config.js @@ -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'); @@ -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' } = {}) { diff --git a/actions/org_sync/report.js b/actions/org_sync/report.js new file mode 100644 index 0000000..e1719a7 --- /dev/null +++ b/actions/org_sync/report.js @@ -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} 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; diff --git a/tests/org_sync/org_sync.test.js b/tests/org_sync/org_sync.test.js index 6646a1d..4b12b5f 100644 --- a/tests/org_sync/org_sync.test.js +++ b/tests/org_sync/org_sync.test.js @@ -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'); @@ -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' })] }] }, diff --git a/tests/org_sync/report.test.js b/tests/org_sync/report.test.js new file mode 100644 index 0000000..0a8e023 --- /dev/null +++ b/tests/org_sync/report.test.js @@ -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); +});