diff --git a/.github/file-sync.md b/.github/file-sync.md new file mode 100644 index 00000000..a4f14064 --- /dev/null +++ b/.github/file-sync.md @@ -0,0 +1,79 @@ +# Organization and file synchronization + +The **Global replicator** workflow uses `LizardByte/actions/actions/org_sync` to reconcile organization configuration +and shared files across the organizations in [organizations.json](../organizations.json). It replaces the old topic-based replicator +and the separate preview workflow. A single checkout supplies the policy and source files. + +| Event | Mode | Scope | +| ------------------------ | -------- | --------------------------------------------- | +| Internal pull request | Dry run | All configured organizations and repositories | +| Push to master | Delivery | All configured organizations and repositories | +| Daily at midnight UTC | Delivery | All configured organizations and repositories | +| Manual workflow dispatch | Delivery | All targets or the selected org/repository | + +Every pull request and master push runs the workflow regardless of which files changed. Fork pull requests +skip the credentialed job. PR runs do not write organization or target repository state. Delivery runs apply +organization configuration directly, then create or update one sync PR per repository; they do not merge those PRs. +Delivery runs share a concurrency +group and are not canceled by subsequent runs. A newer PR preview can cancel an older preview of the same PR. + +After merging this workflow, use **Run workflow** to optionally select an `organization` or exact +`owner/repository`. Empty inputs reconcile all configured targets. The former `repo_name` input is replaced +by the qualified `repository` input. The dedicated bot branch is `bot/org-sync-files`; the action does not +take over branches or PRs created by the old replicator. + +One job and one action invocation handle both administration and file delivery. An exact `repository` dispatch +is file-only: it omits `properties_file` and `admin_config`, so it cannot change the whole organization while +targeting one repository. Use an `organization` dispatch or an unfiltered run to include administration. +Dependabot PRs still preview files, but omit the administration inputs because GitHub does not provide the +normal Actions admin secret to Dependabot runs. The owner credential is not replicated into Dependabot. + +## Policy and selection + +- [organizations.json](../organizations.json) is the shared array of five organization names. +- [org-sync.json](org-sync.json) is the single synchronization policy: `files` contains file rules and 33 cleanup + paths, `properties` contains repository property definitions, and `administration` contains shared organization + settings, credentials, blocked users, webhooks, and security configurations. General files, including Renovate, + use `sync`. `create_if_missing` is available for reviewed exceptions. +- The policy's `properties` definitions are provisioned in every registered org. + `sync_mode: excluded` skips a repository. Multi-select `workflow_profiles` selects additional file groups. + The action now reads this file and reconciles definitions in every selected org before file delivery. + Repository assignments and unrelated definitions are preserved. Changes to defaults can affect repositories + inheriting them; the PR preview shows those changes before delivery. +- Administration targets come directly from `organizations.json`, excluding `source_organization` (LizardByte). + There is no duplicate administration org list. Adding an org enables its shared settings and property schema; + credentials still require explicit destination organizations. New organization credentials default to all-repo + access; value updates preserve manually configured access. See [organization administration](org-sync.md). +- Public, unarchived repositories are eligible by default, including forks. The source repository is skipped. + Existing mirror and Upptime exclusions in LizardByte-infrastructure apply through their properties. +- Deselected profiles remove their configured files on the next run. General files remain, and another selected + rule can retain a shared path. Missing sources fail validation; deletion requires a profile/policy change or + explicit cleanup rule. `_docker.yml` is cleanup-only because its source no longer exists. +- Community health defaults use `repository_names: [".github"]` across `organizations.json`: `FUNDING.yml`, + `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `SECURITY.md`, `SUPPORT.md`, the issue forms/configuration, and the PR template. + These always sync in eligible `.github` repositories, replacing existing versions without requiring a profile. + The `custom_issues` profile still controls issue configuration in ordinary repositories; deselecting it does not + remove an organization's default configuration. Adding an org or creating its `.github` repo needs no policy edit. + Organizations without a `.github` repo are skipped for this rule. Normal eligibility and custom-property checks + still apply, including the source-repository exclusion. + GitHub [inherits these defaults](https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/creating-a-default-community-health-file) + in repositories without their own matching files; repository-specific issue templates/configuration override + the whole default issue-template directory. Other supported defaults such as `ACCESSIBILITY.md`, discussion + forms, and vulnerability-report forms can be added to this rule when source files exist. +- The `docker` and `cpm` profile choices currently have no file mappings. CPM maintenance is a separate workflow. + +## Reviewing a run + +The reusable action writes the run summary and uploads a JSON report artifact with a unique `file-sync-` name. +No local report script or upload step is needed. The summary states whether delivery or dry-run mode was used +and lists each repository's status and file change count. Inspect the artifact's `results.json` for exact paths, +operations, warnings, errors, and target PR URLs. Partial results are saved even when another target fails; a +failed sync still fails the job. Private/archived repositories, the skipped source repository, and empty +organizations produce no result rows. + +The existing `GH_BOT_TOKEN` must read repository metadata/contents and organization custom properties across +the listed orgs. Delivery additionally requires permission to write contents/workflows and manage target PRs. +The token's user must have access in each organization. Missing access is reported per organization or repository. +The workflow's own `GITHUB_TOKEN` has only `contents: read`. Administration uses `ORG_SYNC_ADMIN_TOKEN` when +provided, otherwise `GH_BOT_TOKEN`; that credential needs the organization API permissions described in the +administration guide. Secrets are supplied through explicit environment bindings, never through committed values. diff --git a/.github/org-sync.json b/.github/org-sync.json new file mode 100644 index 00000000..17f6d1f5 --- /dev/null +++ b/.github/org-sync.json @@ -0,0 +1,724 @@ +{ + "version": 1, + "files": { + "id": "org-sync", + "include_archived": false, + "include_forks": true, + "include_private": false, + "include_source": false, + "rules": [ + { + "id": "obsolete-files", + "files": [ + { + "destination": ".codeql-prebuild-cpp-Linux.sh", + "mode": "remove" + }, + { + "destination": ".codeql-prebuild-cpp-Windows.sh", + "mode": "remove" + }, + { + "destination": ".codeql-prebuild-cpp-macOS.sh", + "mode": "remove" + }, + { + "destination": ".github/label-actions.yml", + "mode": "remove" + }, + { + "destination": ".github/pr_release_template.md", + "mode": "remove" + }, + { + "destination": ".github/workflows/_docker.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/auto-create-pr.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/automerge.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/autoupdate.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/autoupdate-labeler.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/ci-docker.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/ci-qodana.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/codeql.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/common-lint.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/cpp-lint.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/dispatcher.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/issues.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/issues-stale.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/pull-requests.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/python-flake8.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/release-notifier.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/top_issues.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-changelog.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-docs.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-flathub-repo.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-homebrew-release.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-pacman-repo.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/update-winget-release.yml", + "mode": "remove" + }, + { + "destination": ".github/workflows/yaml-lint.yml", + "mode": "remove" + }, + { + "destination": "codecov.yml", + "mode": "remove" + }, + { + "destination": "qodana-dotnet.yaml", + "mode": "remove" + }, + { + "destination": "qodana-js.yaml", + "mode": "remove" + }, + { + "destination": "qodana-python.yaml", + "mode": "remove" + } + ] + }, + { + "id": "general", + "files": [ + { + "source": ".github/dependabot.yml" + }, + { + "source": ".github/semantic.yml" + }, + { + "source": ".github/workflows/_codeql.yml" + }, + { + "source": ".github/workflows/_common-lint.yml" + }, + { + "source": ".github/workflows/_top-issues.yml" + }, + { + "source": "renovate.json" + } + ] + }, + { + "id": "docs", + "profile": "docs", + "files": [ + { + "source": ".github/workflows/_update-docs.yml" + } + ] + }, + { + "id": "flathub", + "profile": "flathub", + "files": [ + { + "source": ".github/workflows/_update-flathub-repo.yml" + } + ] + }, + { + "id": "homebrew", + "profile": "homebrew", + "files": [ + { + "source": ".github/workflows/_update-homebrew-repo.yml" + } + ] + }, + { + "id": "pacman", + "profile": "pacman", + "files": [ + { + "source": ".github/workflows/_update-pacman-repo.yml" + } + ] + }, + { + "id": "winget", + "profile": "winget", + "files": [ + { + "source": ".github/workflows/_update-winget-repo.yml" + } + ] + }, + { + "id": "node_ci", + "profile": "node_ci", + "files": [ + { + "source": ".github/workflows/_ci-node.yml" + } + ] + }, + { + "id": "npm", + "profile": "npm", + "files": [ + { + "source": ".github/workflows/_update-npm.yml" + } + ] + }, + { + "id": "python_style", + "profile": "python_style", + "files": [ + { + "source": ".flake8" + } + ] + }, + { + "id": "cpp_style", + "profile": "cpp_style", + "files": [ + { + "source": ".clang-format" + } + ] + }, + { + "id": "custom_issues", + "profile": "custom_issues", + "files": [ + { + "source": ".github/ISSUE_TEMPLATE/config.yml" + } + ] + }, + { + "id": "release_notifications", + "profile": "release_notifications", + "files": [ + { + "source": ".github/workflows/_release-notifier.yml" + }, + { + "source": ".github/workflows/_update-changelog.yml" + } + ] + }, + { + "id": "organization-community-health", + "repository_names": [ + ".github" + ], + "files": [ + { + "source": ".github/FUNDING.yml" + }, + { + "source": ".github/ISSUE_TEMPLATE/bug-report.yml" + }, + { + "source": ".github/ISSUE_TEMPLATE/config.yml" + }, + { + "source": ".github/pull_request_template.md" + }, + { + "source": "CODE_OF_CONDUCT.md" + }, + { + "source": "CONTRIBUTING.md" + }, + { + "source": "SECURITY.md" + }, + { + "source": "SUPPORT.md" + } + ] + } + ] + }, + "properties": [ + { + "property_name": "sync_mode", + "value_type": "single_select", + "required": true, + "default_value": "included", + "description": "Shared file synchronization: included by default; excluded opts the repository out.", + "allowed_values": [ + "included", + "excluded" + ], + "values_editable_by": "org_actors", + "require_explicit_values": false + }, + { + "property_name": "workflow_profiles", + "value_type": "multi_select", + "required": false, + "default_value": null, + "description": "Additional shared workflow and configuration groups. No selection means general replication only.", + "allowed_values": [ + "cpm", + "cpp_style", + "custom_issues", + "docker", + "docs", + "flathub", + "homebrew", + "node_ci", + "npm", + "pacman", + "python_style", + "release_notifications", + "winget" + ], + "values_editable_by": "org_actors", + "require_explicit_values": false + } + ], + "administration": { + "source_organization": "LizardByte", + "settings": { + "from_source": [ + "default_repository_permission", + "deploy_keys_enabled_for_repositories", + "has_organization_projects", + "has_repository_projects", + "location", + "members_can_create_internal_repositories", + "members_can_create_pages", + "members_can_create_private_pages", + "members_can_create_private_repositories", + "members_can_create_public_pages", + "members_can_create_public_repositories", + "members_can_create_repositories", + "members_can_fork_private_repositories", + "twitter_username", + "web_commit_signoff_required" + ] + }, + "actions": { + "from_source": [ + "permissions", + "retention", + "workflow" + ] + }, + "blocked_users": { + "from_source": true, + "remove_extra": false + }, + "security_configurations": [ + { + "source_name": "private/internal repositories", + "copy_default": true + }, + { + "source_name": "strict", + "copy_default": true + } + ], + "variables": [ + { + "name": "AZURE_SIGNING_ACCOUNT", + "from_source": "AZURE_SIGNING_ACCOUNT", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "AZURE_SIGNING_CERT_PROFILE", + "from_source": "AZURE_SIGNING_CERT_PROFILE", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "AZURE_SIGNING_ENDPOINT", + "from_source": "AZURE_SIGNING_ENDPOINT", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "DOCKER_HUB_USERNAME", + "from_source": "DOCKER_HUB_USERNAME", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "GH_BOT_NAME", + "from_source": "GH_BOT_NAME", + "organizations": [ + "SunshineStream", + "Themerr" + ] + } + ], + "secrets": [ + { + "name": "APPLE_CODESIGN_IDENTITY", + "kind": "actions", + "value_env": "APPLE_CODESIGN_IDENTITY", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64", + "kind": "actions", + "value_env": "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD", + "kind": "actions", + "value_env": "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "APPLE_ID", + "kind": "actions", + "value_env": "APPLE_ID", + "organizations": [ + "KokoStreamTV", + "SunshineStream" + ] + }, + { + "name": "APPLE_NOTARYTOOL_PASSWORD", + "kind": "actions", + "value_env": "APPLE_NOTARYTOOL_PASSWORD", + "organizations": [ + "KokoStreamTV", + "SunshineStream" + ] + }, + { + "name": "APPLE_TEAM_ID", + "kind": "actions", + "value_env": "APPLE_TEAM_ID", + "organizations": [ + "KokoStreamTV", + "SunshineStream" + ] + }, + { + "name": "AZURE_CLIENT_ID", + "kind": "actions", + "value_env": "AZURE_CLIENT_ID", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "AZURE_CLIENT_SECRET", + "kind": "actions", + "value_env": "AZURE_CLIENT_SECRET", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "AZURE_TENANT_ID", + "kind": "actions", + "value_env": "AZURE_TENANT_ID", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "CLOUDSMITH_API_KEY", + "kind": "actions", + "value_env": "CLOUDSMITH_API_KEY", + "organizations": [ + "SunshineStream" + ] + }, + { + "name": "COPR_BETA_WEBHOOK_TOKEN", + "kind": "actions", + "value_env": "COPR_BETA_WEBHOOK_TOKEN", + "organizations": [ + "SunshineStream" + ] + }, + { + "name": "COPR_CLI_CONFIG", + "kind": "actions", + "value_env": "COPR_CLI_CONFIG", + "organizations": [ + "SunshineStream" + ] + }, + { + "name": "COPR_STABLE_WEBHOOK_TOKEN", + "kind": "actions", + "value_env": "COPR_STABLE_WEBHOOK_TOKEN", + "organizations": [ + "SunshineStream" + ] + }, + { + "name": "CROWDIN_TOKEN", + "kind": "actions", + "value_env": "CROWDIN_TOKEN", + "organizations": [ + "SunshineStream" + ] + }, + { + "name": "DOCKER_HUB_ACCESS_TOKEN", + "kind": "actions", + "value_env": "DOCKER_HUB_ACCESS_TOKEN", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "DOCKER_HUB_PASSWORD", + "kind": "actions", + "value_env": "DOCKER_HUB_PASSWORD", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "FANART_PROJECT_API_KEY", + "kind": "actions", + "value_env": "FANART_PROJECT_API_KEY", + "organizations": [ + "KokoStreamTV" + ] + }, + { + "name": "GH_BOT_EMAIL", + "kind": "actions", + "value_env": "GH_BOT_EMAIL", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "GH_BOT_EMAIL", + "kind": "dependabot", + "value_env": "GH_BOT_EMAIL", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "GH_BOT_TOKEN", + "kind": "actions", + "value_env": "GH_BOT_TOKEN", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "GH_BOT_TOKEN", + "kind": "dependabot", + "value_env": "GH_BOT_TOKEN", + "organizations": [ + "SunshineStream", + "Themerr" + ] + }, + { + "name": "LRCLIB_APPLICATION_ID", + "kind": "actions", + "value_env": "LRCLIB_APPLICATION_ID", + "organizations": [ + "KokoStreamTV" + ] + }, + { + "name": "READTHEDOCS_TOKEN", + "kind": "actions", + "value_env": "READTHEDOCS_TOKEN", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "TMDB_API_KEY_V3", + "kind": "actions", + "value_env": "TMDB_API_KEY_V3", + "organizations": [ + "Themerr" + ] + }, + { + "name": "TMDB_API_KEY_V3", + "kind": "dependabot", + "value_env": "TMDB_API_KEY_V3", + "organizations": [ + "Themerr" + ] + }, + { + "name": "TWITCH_CLIENT_ID", + "kind": "actions", + "value_env": "TWITCH_CLIENT_ID", + "organizations": [ + "Themerr" + ] + }, + { + "name": "TWITCH_CLIENT_ID", + "kind": "dependabot", + "value_env": "TWITCH_CLIENT_ID", + "organizations": [ + "Themerr" + ] + }, + { + "name": "TWITCH_CLIENT_SECRET", + "kind": "actions", + "value_env": "TWITCH_CLIENT_SECRET", + "organizations": [ + "Themerr" + ] + }, + { + "name": "TWITCH_CLIENT_SECRET", + "kind": "dependabot", + "value_env": "TWITCH_CLIENT_SECRET", + "organizations": [ + "Themerr" + ] + }, + { + "name": "VIRUSTOTAL_API_KEY", + "kind": "actions", + "value_env": "VIRUSTOTAL_API_KEY", + "organizations": [ + "KokoStreamTV", + "SunshineStream", + "Themerr" + ] + }, + { + "name": "YOUTUBE_API_KEY", + "kind": "actions", + "value_env": "YOUTUBE_API_KEY", + "organizations": [ + "Themerr" + ] + }, + { + "name": "YOUTUBE_API_KEY", + "kind": "dependabot", + "value_env": "YOUTUBE_API_KEY", + "organizations": [ + "Themerr" + ] + } + ], + "webhooks": [ + { + "id": "discord", + "source_url_prefix": "https://discord.com/api/webhooks/", + "unsigned": true + } + ] + } +} diff --git a/.github/org-sync.md b/.github/org-sync.md new file mode 100644 index 00000000..7369af20 --- /dev/null +++ b/.github/org-sync.md @@ -0,0 +1,101 @@ +# Organization administration + +The Global replicator invokes `org_sync` once for both administration and files. Its +[single policy](org-sync.json) contains `files`, `properties`, and `administration` sections. LizardByte is the +administration source; all other orgs come directly from [organizations.json](../organizations.json), with no +second target list. Pull requests preview every operation; +master pushes, the daily schedule, and manual organization/full runs deliver them. Exact-repository runs +replicate files only. Administrative failure prevents file delivery in that invocation. +Dependabot-triggered runs retain file synchronization but omit administration: GitHub withholds normal Actions +secrets from those runs, and the organization-admin credential is not granted to Dependabot. + +## Managed configuration + +| Resource | Policy | +| ----------------------- | ---------------------------------------------------------------------------- | +| Custom-property schemas | Reconcile definitions across the registry; preserve repository values | +| Shared org settings | Copy supported shared fields; retain company, name, description, and website | +| Actions settings | Copy permissions, workflow defaults, and retention | +| Blocked users | Add source blocked users; retain destination-only blocks | +| Security configurations | Copy public and private/internal configurations and new-repository defaults | +| Actions variables | Copy names required by active projects to their destination orgs | +| Actions secrets | Supply values required by active projects to their destination orgs | +| Dependabot secrets | Supply values required by active pull-request workflows | +| Organization webhooks | Copy source Discord hooks by URL prefix; exclude CLA Assistant | + +Each project org retains its current company, name, description, and website. `company` is a separate profile +field; the organization display name uses `name`. Other shared profile fields, supported +member privileges, default repository permissions, project/Pages creation controls, private forking, signoff, +and deploy-key policy follow LizardByte. A null profile string is treated as empty, matching GitHub's readback. +The source currently allows all Actions, so its inactive selected-actions allowlist is not requested. +If LizardByte switches to selected actions or repositories, update the policy with the corresponding allowlist. + +The two security defaults cover public and private/internal repositories separately. Configuration IDs are +resolved in each destination. Existing repositories are not attached or scanned by this policy. Public/private +feature availability and enterprise licensing still apply. + +## Credentials and access + +All synced secrets and variables belong to the destination **organization**. Repository-level copies are not +created. Each entry names its destination orgs; adding an org to the shared registry does not automatically +distribute credentials to it. +Credentials with the same source and settings share one entry listing all their destination organizations. + +The policy omits access fields. New credentials are created with access to **all repositories** in their +destination org. Later value updates preserve the existing visibility and selected-repository list. You can +restrict access manually in the destination organization settings, and subsequent runs retain your choices. +Credentials can be provisioned before project repositories transfer. + +The [test fixture](../tests/fixtures/org-sync-consumers.json) lists expected variable and secret names by +destination organization. Tests compare these organization-level expectations against the policy. +LizardByte-infrastructure has no credential entries. Repository exclusions from file sync do not restrict +access to organization credentials. + +Configure each destination org's `CODECOV_TOKEN` manually in its Actions and Dependabot secrets as required by +its workflows. Codecov tokens are outside the sync policy and have no controller bindings. + +Shared variables are copied by name from LizardByte. `READTHEDOCS_SLUG` remains a project-specific repository +override. Shared secret values come from explicit workflow secret bindings; GitHub cannot export stored secret +plaintext. Source-only social, AUR, CLA, GitLab rotation, branding, and monitoring credentials are excluded. +Dependabot entries contain only names required by the active pull-request workflows. + +Every delivery resends configured secret values to support rotation. Variables are updated only when their +value changes. Missing supplied values fail delivery rather than clearing a secret. Omitting a credential +from the policy does not delete it from a destination. + +`ORG_SYNC_ADMIN_TOKEN` supplies the administration credential when configured; otherwise the workflow uses +`GH_BOT_TOKEN`. It must administer the destinations and read the source. Classic tokens need `admin:org`; +webhook management also needs `admin:org_hook`. File delivery needs repository, workflow, and pull-request +write access. GitHub App tokens need the API-specific organization permissions listed in the +[action guide](https://github.com/LizardByte/actions/blob/2dfa92c5136a2c66c5614ec766cc679f6f537334/actions/org_sync/administration.md). +`ORG_SYNC_ADMIN_TOKEN` is controller-only and is not replicated. + +## Discord webhooks + +The action selects `web` hooks under `https://discord.com/api/webhooks/` from the source org at runtime, then +copies each hook's receiver URL, event subscriptions, activity, and content type to the destinations. This +follows matching source hooks automatically. +CLA Assistant is outside this prefix and is not copied. The Discord URL tokens are never committed or written +to the report. Discord receiver authentication is in the URL; `unsigned: true` explicitly configures delivery +without a separate GitHub HMAC signing secret. + +Webhook discovery and replication both happen inside `org_sync`. The source credential must be able to read +those existing hooks. GitHub's OAuth webhook restrictions can produce a filtered empty response despite the +`admin:org_hook` scope; use a suitable PAT/App credential. An empty selected group fails rather than silently +skipping webhook synchronization. PRs only preview the copies; no webhook is created or updated until delivery. + +## Transfer prerequisites + +- Ensure the controller can read every source secret referenced by the workflow, including project API keys + and Crowdin. Preserve existing source access when granting the controller access. +- Configure destination Codecov credentials manually and choose repository access in each org as needed. +- Ensure the administration credential has the required permissions across the registered orgs. +- Update project/shared workflow owner guards and publishing locations before transfer, including Crowdin + and shared Node CI for `awesome-sunshine`; retain its `node_ci` profile. +- Review additional webhook receivers and supply signing secrets when required. +- Set organization default labels/default branch names, extra social links, package policies, standalone-org + 2FA, and PAT policies manually where supported write APIs are unavailable. + +Review the combined report's organization before/after settings, schema defaults, credential access counts, +and per-operation status before merging. Delivery is not transactional: a failed later write can leave earlier +operations applied. Correct the reported cause and rerun; the action does not roll back newer organization state. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 69a9f990..91ca55c8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -10,8 +10,16 @@ respective repositories. Workflows that prefixed with a single `_` are replicated to other repositories and this indicates that the workflow should not be modified when inside the other repositories. -Repositories excluded from both the global replicator and GitLab mirror are listed in -`.github/repositories-to-ignore.json`. +The [global replicator](../file-sync.md) selects repositories through the `sync_mode` and `workflow_profiles` +custom properties. It previews every internal PR and delivers on master pushes, daily, or manually. + +Release-notification and package-publishing workflows check the caller's `workflow_profiles` custom property +through the repository API. Their profiles are `release_notifications`, `flathub`, `homebrew`, `pacman`, and +`winget`. The CPM maintenance matrix selects `cpm` through the same property. Missing, null, or empty profile +selections disable that workflow group; invalid values or failed property reads fail the check. These runtime +checks are independent of the file replicator's `sync_mode` setting. + +Repositories excluded from the GitLab mirror are listed in `.github/repositories-to-ignore.json`. The GitLab mirror also excludes GitHub temporary security advisory forks matching `-ghsa-xxxx-xxxx-xxxx`. diff --git a/.github/workflows/__call-release-notifier.yml b/.github/workflows/__call-release-notifier.yml index cce5205e..2b565c41 100644 --- a/.github/workflows/__call-release-notifier.yml +++ b/.github/workflows/__call-release-notifier.yml @@ -30,30 +30,28 @@ jobs: env: BLOG_REPO: ${{ github.repository_owner }}/${{ github.repository_owner }}.github.io steps: - - name: Check topics + - name: Check workflow profile env: - TOPIC: replicator-release-notifications - id: check-label + WORKFLOW_PROFILE: release_notifications + id: check-profile uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const topic = process.env.TOPIC; - console.log(`Checking if repo has topic: ${topic}`); - - const repoTopics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: context.repo.repo }); - console.log(`Repo topics: ${repoTopics.data.names}`); - - const hasTopic = repoTopics.data.names.includes(topic); - console.log(`Has topic: ${hasTopic}`); - - core.setOutput('hasTopic', hasTopic); + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error('Invalid workflow_profiles custom property'); + } + const enabled = profiles.includes(process.env.WORKFLOW_PROFILE); + core.info(`Workflow profile ${process.env.WORKFLOW_PROFILE}: ${enabled}`); + core.setOutput('enabled', enabled); - name: Check if latest GitHub release id: check-release - if: steps.check-label.outputs.hasTopic == 'true' + if: steps.check-profile.outputs.enabled == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -66,7 +64,7 @@ jobs: - name: Checkout blog if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -74,7 +72,7 @@ jobs: - name: Create blog post if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' # Keep event data out of the generated shell script so Markdown is never evaluated as shell syntax. env: @@ -125,7 +123,7 @@ jobs: - name: Create/Update Pull Request id: create-pr if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: @@ -146,7 +144,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_TOKEN }} if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' run: | gh pr merge \ diff --git a/.github/workflows/__call-update-flathub-repo.yml b/.github/workflows/__call-update-flathub-repo.yml index bc4e1ec9..082e4b55 100644 --- a/.github/workflows/__call-update-flathub-repo.yml +++ b/.github/workflows/__call-update-flathub-repo.yml @@ -28,30 +28,28 @@ jobs: contents: read runs-on: ubuntu-latest steps: - - name: Check if Flathub repo - id: check-label + - name: Check workflow profile + id: check-profile env: - TOPIC: flathub-pkg + WORKFLOW_PROFILE: flathub uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const topic = process.env.TOPIC; - console.log(`Checking if repo has topic: ${topic}`); - - const repoTopics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: context.repo.repo }); - console.log(`Repo topics: ${repoTopics.data.names}`); - - const hasTopic = repoTopics.data.names.includes(topic); - console.log(`Has topic: ${hasTopic}`); - - core.setOutput('hasTopic', hasTopic); + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error('Invalid workflow_profiles custom property'); + } + const enabled = profiles.includes(process.env.WORKFLOW_PROFILE); + core.info(`Workflow profile ${process.env.WORKFLOW_PROFILE}: ${enabled}`); + core.setOutput('enabled', enabled); - name: Check if latest GitHub release id: check-release - if: steps.check-label.outputs.hasTopic == 'true' + if: steps.check-profile.outputs.enabled == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -64,13 +62,13 @@ jobs: - name: Checkout if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout flathub-repo if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -79,7 +77,7 @@ jobs: - name: Clean up legacy files if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' working-directory: flathub/${{ env.FLATHUB_PKG }} run: | @@ -88,7 +86,7 @@ jobs: - name: Download release asset id: download if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13 with: @@ -102,14 +100,14 @@ jobs: - name: Delete archive if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' run: | rm -f flathub/${{ env.FLATHUB_PKG }}/flathub.tar.gz - name: Copy github files if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' working-directory: flathub/${{ env.FLATHUB_PKG }} run: | @@ -139,7 +137,7 @@ jobs: - name: Check if shared-modules submodule exists id: check-submodule if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' env: SUBMODULE_PATH: packaging/linux/flatpak/deps/shared-modules @@ -154,7 +152,7 @@ jobs: - name: Update submodule if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' && steps.check-submodule.outputs.exists == 'true' run: | @@ -172,7 +170,7 @@ jobs: - name: Create/Update Pull Request id: create-pr if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 diff --git a/.github/workflows/__call-update-homebrew-repo.yml b/.github/workflows/__call-update-homebrew-repo.yml index e6010816..cd614fd5 100644 --- a/.github/workflows/__call-update-homebrew-repo.yml +++ b/.github/workflows/__call-update-homebrew-repo.yml @@ -26,30 +26,28 @@ jobs: name: Homebrew url: https://github.com/${{ github.repository_owner }}/homebrew-homebrew/commits steps: - - name: Check if Homebrew repo - id: check-label + - name: Check workflow profile + id: check-profile env: - TOPIC: homebrew-pkg + WORKFLOW_PROFILE: homebrew uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const topic = process.env.TOPIC; - console.log(`Checking if repo has topic: ${topic}`); - - const repoTopics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: context.repo.repo }); - console.log(`Repo topics: ${repoTopics.data.names}`); - - const hasTopic = repoTopics.data.names.includes(topic); - console.log(`Has topic: ${hasTopic}`); - - core.setOutput('hasTopic', hasTopic); + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error('Invalid workflow_profiles custom property'); + } + const enabled = profiles.includes(process.env.WORKFLOW_PROFILE); + core.info(`Workflow profile ${process.env.WORKFLOW_PROFILE}: ${enabled}`); + core.setOutput('enabled', enabled); - name: Download release asset id: download - if: steps.check-label.outputs.hasTopic == 'true' + if: steps.check-profile.outputs.enabled == 'true' uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13 with: repository: "${{ github.repository }}" @@ -62,7 +60,7 @@ jobs: - name: Publish Homebrew Formula if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] uses: LizardByte/actions/actions/release_homebrew@master with: diff --git a/.github/workflows/__call-update-pacman-repo.yml b/.github/workflows/__call-update-pacman-repo.yml index 432a67b6..191f7f8d 100644 --- a/.github/workflows/__call-update-pacman-repo.yml +++ b/.github/workflows/__call-update-pacman-repo.yml @@ -28,31 +28,29 @@ jobs: env: PACMAN_REPO: ${{ github.repository_owner }}/pacman-repo steps: - - name: Check if pacman repo - id: check-label + - name: Check workflow profile + id: check-profile env: - TOPIC: pacman-pkg + WORKFLOW_PROFILE: pacman uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const topic = process.env.TOPIC; - console.log(`Checking if repo has topic: ${topic}`); - - const repoTopics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: context.repo.repo }); - console.log(`Repo topics: ${repoTopics.data.names}`); - - const hasTopic = repoTopics.data.names.includes(topic); - console.log(`Has topic: ${hasTopic}`); - - core.setOutput('hasTopic', hasTopic); + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error('Invalid workflow_profiles custom property'); + } + const enabled = profiles.includes(process.env.WORKFLOW_PROFILE); + core.info(`Workflow profile ${process.env.WORKFLOW_PROFILE}: ${enabled}`); + core.setOutput('enabled', enabled); - name: Check if latest GitHub release id: check-release if: >- - steps.check-label.outputs.hasTopic == 'true' + steps.check-profile.outputs.enabled == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -65,7 +63,7 @@ jobs: - name: Checkout pacman-repo if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -74,7 +72,7 @@ jobs: - name: Prep id: prep if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' run: | echo "pkg_name=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> "${GITHUB_OUTPUT}" @@ -82,7 +80,7 @@ jobs: - name: Download release asset id: download if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13 with: @@ -96,7 +94,7 @@ jobs: - name: Remove pkg.tar.gz if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] run: rm -f "${{ fromJson(steps.download.outputs.downloaded_files)[0] }}" @@ -104,7 +102,7 @@ jobs: - name: Create/Update Pull Request id: create-pr if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -127,7 +125,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_TOKEN }} if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && steps.check-release.outputs.isLatestRelease == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] run: | diff --git a/.github/workflows/__call-update-winget-repo.yml b/.github/workflows/__call-update-winget-repo.yml index ea468181..bf4d15b4 100644 --- a/.github/workflows/__call-update-winget-repo.yml +++ b/.github/workflows/__call-update-winget-repo.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `winget-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `winget` in the `workflow_profiles` repository custom property. # Update Winget on release events. @@ -26,30 +26,28 @@ jobs: name: Winget url: ${{ steps.winget.outputs.pr-url }} steps: - - name: Check if Winget repo - id: check-label + - name: Check workflow profile + id: check-profile env: - TOPIC: winget-pkg + WORKFLOW_PROFILE: winget uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const topic = process.env.TOPIC; - console.log(`Checking if repo has topic: ${topic}`); - - const repoTopics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: context.repo.repo }); - console.log(`Repo topics: ${repoTopics.data.names}`); - - const hasTopic = repoTopics.data.names.includes(topic); - console.log(`Has topic: ${hasTopic}`); - - core.setOutput('hasTopic', hasTopic); + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error('Invalid workflow_profiles custom property'); + } + const enabled = profiles.includes(process.env.WORKFLOW_PROFILE); + core.info(`Workflow profile ${process.env.WORKFLOW_PROFILE}: ${enabled}`); + core.setOutput('enabled', enabled); - name: Download release asset id: download - if: steps.check-label.outputs.hasTopic == 'true' + if: steps.check-profile.outputs.enabled == 'true' uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13 with: repository: "${{ github.repository }}" @@ -62,7 +60,7 @@ jobs: - name: Sync WinGet fork if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] env: GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -72,7 +70,7 @@ jobs: - name: Release to WinGet id: winget if: >- - steps.check-label.outputs.hasTopic == 'true' && + steps.check-profile.outputs.enabled == 'true' && fromJson(steps.download.outputs.downloaded_files)[0] uses: vedantmgoyal9/winget-releaser@b7370d4d152efdce803a26cc6ca9eee58cb2ad54 # v2 with: diff --git a/.github/workflows/__global-replicator.yml b/.github/workflows/__global-replicator.yml index 37be73d0..78dcb2d0 100644 --- a/.github/workflows/__global-replicator.yml +++ b/.github/workflows/__global-replicator.yml @@ -1,368 +1,93 @@ --- -# This workflow does not need to be replicated to other repositories. This workflow will update the files in other org -# repos. - +# This workflow runs only in the source .github repository. name: Global replicator permissions: {} on: + pull_request: push: branches: - - master # only files that changed in the commit will be replicated, unless using `workflow_dispatch` + - master + schedule: + - cron: '0 0 * * *' # Daily at midnight UTC. workflow_dispatch: inputs: - repo_name: - description: | - You can specify the repository's name where workflows should be pushed manually, as long as workflow settings - do not ignore the repository. If you do not specify the exact repository name, the workflow will try to - replicate all missing changes to all repositories. + organization: + description: 'Optional organization from organizations.json; leave empty to sync all organizations.' + required: false + type: string + default: '' + repository: + description: 'Optional exact owner/repository; leave empty to sync all configured targets.' required: false + type: string + default: '' + +concurrency: + # All delivery runs share one group, even when dispatched from different refs. + group: global-replicator-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'delivery' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: replicate: - name: Replicate files - env: - BOT_BRANCH_NAME: 'bot/update-files-from-global-repo' - COMMIT_MESSAGE: 'chore: update global workflows' - EXCLUDE_PRIVATE: true + name: Synchronize organizations and files + if: >- + github.repository == 'LizardByte/.github' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) permissions: contents: read runs-on: ubuntu-latest + env: + ORG_SYNC_CONFIG: .github/org-sync.json steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Load repositories to ignore - shell: bash - run: | - set -euo pipefail - - ignore_file=".github/repositories-to-ignore.json" - repos_to_ignore="$( - jq -er ' - if type == "array" and all(.[]; type == "string" and length > 0) - then join(",") - else error("Expected an array of non-empty repository names") - end - ' "${ignore_file}" - )" - echo "REPOS_TO_IGNORE=${repos_to_ignore}" >> "${GITHUB_ENV}" - echo "Loaded $(jq -r 'length' "${ignore_file}") repositories to ignore." - - - name: Removing files - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_remove: >- - .codeql-prebuild-cpp-Linux.sh, - .codeql-prebuild-cpp-Windows.sh, - .codeql-prebuild-cpp-macOS.sh, - .github/label-actions.yml, - .github/pr_release_template.md, - .github/workflows/_docker.yml, - .github/workflows/auto-create-pr.yml, - .github/workflows/automerge.yml, - .github/workflows/autoupdate.yml, - .github/workflows/autoupdate-labeler.yml, - .github/workflows/ci-docker.yml, - .github/workflows/ci-qodana.yml, - .github/workflows/codeql.yml, - .github/workflows/common-lint.yml, - .github/workflows/cpp-lint.yml, - .github/workflows/dispatcher.yml, - .github/workflows/issues.yml, - .github/workflows/issues-stale.yml, - .github/workflows/pull-requests.yml, - .github/workflows/python-flake8.yml, - .github/workflows/release-notifier.yml, - .github/workflows/top_issues.yml, - .github/workflows/update-changelog.yml, - .github/workflows/update-docs.yml, - .github/workflows/update-flathub-repo.yml, - .github/workflows/update-homebrew-release.yml, - .github/workflows/update-pacman-repo.yml, - .github/workflows/update-winget-release.yml, - .github/workflows/yaml-lint.yml, - codecov.yml, - qodana-dotnet.yaml, - qodana-js.yaml, - qodana-python.yaml - topics_to_include: '' - exclude_forked: false - destination: '' - - # checkout between each step to ensure we have a clean GitHub workspace - # see https://github.com/derberg/manage-files-in-multiple-repositories/issues/61#issuecomment-1747013728 - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (general) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/dependabot.yml, - .github/semantic.yml, - .github/workflows/_codeql.yml, - .github/workflows/_common-lint.yml, - .github/workflows/_top-issues.yml, - renovate.json - topics_to_include: '' - exclude_forked: false - destination: '' - - - name: Checkout repository + - name: Checkout source policy and files uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (docs) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-docs.yml - topics_to_include: 'rtd' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (flathub) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-flathub-repo.yml - topics_to_include: 'flathub-pkg' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (homebrew) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-homebrew-repo.yml - topics_to_include: 'homebrew-pkg' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (pacman) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-pacman-repo.yml - topics_to_include: 'pacman-pkg' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (winget) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-winget-repo.yml - topics_to_include: 'winget-pkg' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (node CI) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_ci-node.yml - topics_to_include: 'nodejs' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (npm) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_update-npm.yml - topics_to_include: 'npm-pkg' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (python) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .flake8 - topics_to_include: 'python' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (docker) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_docker.yml - topics_to_include: 'docker' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (c, cpp) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .clang-format - topics_to_include: 'c,cpp' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (custom issues) - # GitHub will not show the global issue config if there are any custom templates in the repo - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 - with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/ISSUE_TEMPLATE/config.yml - topics_to_include: 'replicator-custom-issues' - exclude_forked: false - destination: '' - - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Replicating files (release/changelog workflows) - uses: derberg/manage-files-in-multiple-repositories@b64d9c8480606c15f957cdda078f0122815512b8 # v3.1.2 + persist-credentials: false + + - name: Synchronize organizations and shared files + id: sync + uses: LizardByte/actions/actions/org_sync@2dfa92c5136a2c66c5614ec766cc679f6f537334 # v2026.1107.144430 + env: + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + COPR_BETA_WEBHOOK_TOKEN: ${{ secrets.COPR_BETA_WEBHOOK_TOKEN }} + COPR_CLI_CONFIG: ${{ secrets.COPR_CLI_CONFIG }} + COPR_STABLE_WEBHOOK_TOKEN: ${{ secrets.COPR_STABLE_WEBHOOK_TOKEN }} + CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} + FANART_PROJECT_API_KEY: ${{ secrets.FANART_PROJECT_API_KEY }} + GH_BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + GH_BOT_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + LRCLIB_APPLICATION_ID: ${{ secrets.LRCLIB_APPLICATION_ID }} + READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }} + TMDB_API_KEY_V3: ${{ secrets.TMDB_API_KEY_V3 }} + TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} + TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} with: - bot_branch_name: ${{ env.BOT_BRANCH_NAME }} - commit_message: ${{ env.COMMIT_MESSAGE }} - exclude_private: ${{ env.EXCLUDE_PRIVATE }} - repos_to_ignore: ${{ env.REPOS_TO_IGNORE }} - github_token: ${{ secrets.GH_BOT_TOKEN }} - committer_username: ${{ vars.GH_BOT_NAME }} - committer_email: ${{ secrets.GH_BOT_EMAIL }} - patterns_to_ignore: '' - patterns_to_include: >- - .github/workflows/_release-notifier.yml, - .github/workflows/_update-changelog.yml - topics_to_include: 'replicator-release-notifications' - exclude_forked: false - destination: '' + admin_config: >- + ${{ inputs.repository == '' && github.actor != 'dependabot[bot]' && + env.ORG_SYNC_CONFIG || '' }} + admin_token: ${{ secrets.ORG_SYNC_ADMIN_TOKEN || secrets.GH_BOT_TOKEN }} + config: ${{ env.ORG_SYNC_CONFIG }} + organizations_file: organizations.json + organization: ${{ inputs.organization }} + repository: ${{ inputs.repository }} + properties_file: >- + ${{ inputs.repository == '' && github.actor != 'dependabot[bot]' && + env.ORG_SYNC_CONFIG || '' }} + dry_run: ${{ github.event_name == 'pull_request' }} + token: ${{ secrets.GH_BOT_TOKEN }} diff --git a/.github/workflows/__update-cpm-cmake.yml b/.github/workflows/__update-cpm-cmake.yml index dd949e60..e11dd863 100644 --- a/.github/workflows/__update-cpm-cmake.yml +++ b/.github/workflows/__update-cpm-cmake.yml @@ -1,5 +1,5 @@ --- -# This workflow looks for a CPM.cmake file in each repository in the organization, if the repo has the `cpm` label. +# This workflow looks for CPM.cmake in repos that select `cpm` in the `workflow_profiles` custom property. # If found, it updates the CPM.cmake file to the latest version from the CPM.cmake repository, and # creates a pull request with the changes. @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.result }} + has-repositories: ${{ steps.set-matrix.outputs.has-repositories }} permissions: {} steps: - name: Get repos @@ -27,21 +28,26 @@ jobs: const opts = github.rest.repos.listForOrg.endpoint.merge({ org: context.repo.owner }); const repos = await github.paginate(opts); - let matrix = { "include": [] }; + const matrix = { "include": [] }; for (const repo of repos) { - const topics = await github.rest.repos.getAllTopics({ + const { data: values } = await github.request('GET /repos/{owner}/{repo}/properties/values', { owner: context.repo.owner, repo: repo.name }); - if (topics.data.names.includes('cpm')) { + const profiles = values.find(property => property.property_name === 'workflow_profiles')?.value ?? []; + if (!Array.isArray(profiles) || !profiles.every(profile => typeof profile === 'string')) { + throw new Error(`Invalid workflow_profiles custom property for ${repo.full_name}`); + } + if (profiles.includes('cpm')) { matrix.include.push({ "repo": repo.name }); } } + core.setOutput('has-repositories', matrix.include.length > 0); return matrix; test-matrix: name: Test Matrix - ${{ matrix.repo }} - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && needs.setup-matrix.outputs.has-repositories == 'true' needs: setup-matrix permissions: {} runs-on: ubuntu-latest @@ -54,7 +60,7 @@ jobs: update-cpm-cmake: name: Update CPM.cmake - ${{ matrix.repo }} - if: github.event_name == 'schedule' + if: github.event_name == 'schedule' && needs.setup-matrix.outputs.has-repositories == 'true' needs: setup-matrix permissions: {} runs-on: ubuntu-latest diff --git a/.github/workflows/_ci-node.yml b/.github/workflows/_ci-node.yml index 7faae955..c3c24933 100644 --- a/.github/workflows/_ci-node.yml +++ b/.github/workflows/_ci-node.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `nodejs` repository topic to identify repositories that should trigger this workflow. +# To use, select `node_ci` in the `workflow_profiles` repository custom property. # This will run standard CI for Node.js/npm/TypeScript projects. diff --git a/.github/workflows/_update-docs.yml b/.github/workflows/_update-docs.yml index 863f92d5..22c2a517 100644 --- a/.github/workflows/_update-docs.yml +++ b/.github/workflows/_update-docs.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `rtd` repository label to identify repositories that should trigger this workflow. +# To use, select `docs` in the `workflow_profiles` repository custom property. # If the project slug is not the repository name, add a repository variable named `READTHEDOCS_SLUG` with the value of # the ReadTheDocs project slug. diff --git a/.github/workflows/_update-flathub-repo.yml b/.github/workflows/_update-flathub-repo.yml index e9d1050d..4b75a248 100644 --- a/.github/workflows/_update-flathub-repo.yml +++ b/.github/workflows/_update-flathub-repo.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `flathub-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `flathub` in the `workflow_profiles` repository custom property. # Update Flathub on release events. diff --git a/.github/workflows/_update-homebrew-repo.yml b/.github/workflows/_update-homebrew-repo.yml index 7b07b4e5..cdd96ddb 100644 --- a/.github/workflows/_update-homebrew-repo.yml +++ b/.github/workflows/_update-homebrew-repo.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `homebrew-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `homebrew` in the `workflow_profiles` repository custom property. # Update Homebrew on release events. diff --git a/.github/workflows/_update-npm.yml b/.github/workflows/_update-npm.yml index df4e2122..a267db10 100644 --- a/.github/workflows/_update-npm.yml +++ b/.github/workflows/_update-npm.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `npm-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `npm` in the `workflow_profiles` repository custom property. # Update NPM packages on release events. diff --git a/.github/workflows/_update-pacman-repo.yml b/.github/workflows/_update-pacman-repo.yml index ca464678..89a67aae 100644 --- a/.github/workflows/_update-pacman-repo.yml +++ b/.github/workflows/_update-pacman-repo.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `pacman-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `pacman` in the `workflow_profiles` repository custom property. # Update pacman repo on release events. diff --git a/.github/workflows/_update-winget-repo.yml b/.github/workflows/_update-winget-repo.yml index d9cf5f17..9bb4d8e9 100644 --- a/.github/workflows/_update-winget-repo.yml +++ b/.github/workflows/_update-winget-repo.yml @@ -3,7 +3,7 @@ # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in # the above-mentioned repo. -# To use, add the `winget-pkg` repository label to identify repositories that should trigger this workflow. +# To use, select `winget` in the `workflow_profiles` repository custom property. # Update Winget on release events. diff --git a/SUPPORT.md b/SUPPORT.md index def71172..0d036bb0 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -8,12 +8,12 @@ **First, read the documentation!** Many common questions are answered in our comprehensive docs. -| Platform | Support Type | Description | -|----------------------------------------------------------------------------|-----------------------|----------------------------------------| -| 📖 [Read the Docs](https://docs.lizardbyte.dev/latest/about/overview.html) | **Documentation** | Official documentation and guides | -| 💬 [Discord](https://discord.com/invite/d6MpcrbYQs) | **Primary Support** | Real-time chat support | -| 🐙 [GitHub Discussions](https://github.com/orgs/LizardByte/discussions) | **Primary Support** | Feature requests and general discourse | -| 👽 [Reddit](https://www.reddit.com/r/LizardByte) | **Community Support** | Community-driven support | +| Platform | Support Type | Description | +|-------------------------------------------------------------------------|-----------------------|----------------------------------------| +| 📖 [Read the Docs](https://docs.lizardbyte.dev/latest/) | **Documentation** | Official documentation and guides | +| 💬 [Discord](https://discord.com/invite/d6MpcrbYQs) | **Primary Support** | Real-time chat support | +| 🐙 [GitHub Discussions](https://github.com/orgs/LizardByte/discussions) | **Primary Support** | Feature requests and general discourse | +| 👽 [Reddit](https://www.reddit.com/r/LizardByte) | **Community Support** | Community-driven support | ## Additional Resources diff --git a/organizations.json b/organizations.json new file mode 100644 index 00000000..267f9b57 --- /dev/null +++ b/organizations.json @@ -0,0 +1,7 @@ +[ + "KokoStreamTV", + "LizardByte", + "LizardByte-infrastructure", + "SunshineStream", + "Themerr" +] diff --git a/tests/file-sync-policy.test.mjs b/tests/file-sync-policy.test.mjs new file mode 100644 index 00000000..8d1632ab --- /dev/null +++ b/tests/file-sync-policy.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import {existsSync, readFileSync, readdirSync} from 'node:fs'; +import test from 'node:test'; + +const configuration = JSON.parse(readFileSync('.github/org-sync.json', 'utf8')); +const policy = {...configuration.files, version: configuration.version}; +const organizations = JSON.parse(readFileSync('organizations.json', 'utf8')); +const schema = {properties: configuration.properties}; +const workflow = readFileSync('.github/workflows/__global-replicator.yml', 'utf8'); +const compareText = (left, right) => left.localeCompare(right); + +test('general files apply without profiles and each optional group has its own property selection', () => { + const general = policy.rules.find(rule => rule.id === 'general'); + assert.equal(general.profile, undefined); + assert.equal(general.repositories, undefined); + assert.ok(general.files.some(file => file.source === 'renovate.json')); + const profiles = policy.rules.filter(rule => rule.profile); + assert.equal(profiles.length, 11); + for (const rule of profiles) { + assert.equal(rule.profile, rule.id); + assert.equal(rule.repositories, undefined); + } + for (const file of policy.rules.flatMap(rule => rule.files).filter(file => file.source)) { + assert.equal(file.destination ?? file.source, file.source); + assert.equal(file.mode ?? 'sync', 'sync'); + } +}); + +test('cleanup remains explicit and the obsolete Docker wrapper is never a source', () => { + const actual = policy.rules.flatMap(rule => rule.files).filter(file => file.mode === 'remove'); + assert.equal(actual.length, 33); + assert.equal(new Set(actual.map(file => file.destination)).size, actual.length); + assert.ok(actual.some(file => file.destination === '.github/workflows/_docker.yml')); + assert.ok(actual.every(file => file.source === undefined)); + const sources = policy.rules.flatMap(rule => rule.files).filter(file => file.source).map(file => file.source); + assert.ok(actual.every(file => !sources.includes(file.destination))); +}); + +test('configured sources exist and policy selectors use the shared organization list and property schema', () => { + assert.equal(policy.version, 1); + assert.equal(existsSync('.github/file-sync.json'), false); + assert.equal(existsSync('repository-properties.json'), false); + assert.ok(organizations.every(org => typeof org === 'string')); + assert.equal(new Set(organizations).size, organizations.length); + assert.deepEqual(organizations, organizations.toSorted(compareText)); + const profileSchema = schema.properties.find(property => property.property_name === 'workflow_profiles'); + for (const rule of policy.rules) { + if (rule.profile) { + assert.ok(profileSchema.allowed_values.includes(rule.profile), `Unknown profile: ${rule.profile}`); + } + for (const org of rule.organizations ?? []) assert.ok(organizations.includes(org)); + for (const repo of rule.repositories ?? []) assert.ok(organizations.includes(repo.split('/')[0])); + for (const file of rule.files) { + if (file.source) assert.ok(existsSync(file.source), `Missing source file: ${file.source}`); + } + } + const sync = schema.properties.find(property => property.property_name === 'sync_mode'); + assert.equal(sync.required, true); + assert.equal(sync.default_value, 'included'); + assert.equal(profileSchema.value_type, 'multi_select'); + assert.equal(profileSchema.required, false); + assert.equal(profileSchema.default_value, null); + assert.deepEqual(profileSchema.allowed_values, profileSchema.allowed_values.toSorted(compareText)); +}); + +test('all existing community health defaults target .github without a profile or fixed organization list', () => { + const sources = [ + 'ACCESSIBILITY.md', 'CODE_OF_CONDUCT.md', 'CONTRIBUTING.md', 'SECURITY.md', 'SUPPORT.md', + '.github/FUNDING.yml', '.github/VULNERABILITY_REPORT.yml', '.github/VULNERABILITY_REPORT.yaml', + '.github/pull_request_template.md', + ].filter(existsSync); + for (const directory of ['.github/ISSUE_TEMPLATE', '.github/PULL_REQUEST_TEMPLATE', '.github/DISCUSSION_TEMPLATE']) { + if (!existsSync(directory)) continue; + sources.push(...readdirSync(directory, {recursive: true, withFileTypes: true}) + .filter(entry => entry.isFile()) + .map(entry => `${entry.parentPath}/${entry.name}`.replaceAll('\\', '/'))); + } + assert.ok(sources.includes('.github/ISSUE_TEMPLATE/config.yml')); + assert.ok(sources.includes('SUPPORT.md')); + for (const source of sources) { + const defaults = policy.rules.filter(rule => !rule.profile && rule.files.some(file => file.source === source)); + assert.equal(defaults.length, 1, `Missing unconditional community health default: ${source}`); + assert.deepEqual(defaults[0].repository_names, ['.github']); + assert.equal(defaults[0].repositories, undefined); + assert.equal(defaults[0].organizations, undefined); + const file = defaults[0].files.find(file => file.source === source); + assert.equal(file.destination ?? source, source); + assert.equal(file.mode ?? 'sync', 'sync'); + } + const issueConfig = policy.rules.find(rule => rule.profile === 'custom_issues').files + .find(file => file.source === '.github/ISSUE_TEMPLATE/config.yml'); + assert.ok(issueConfig, 'Ordinary repositories must retain profile-controlled issue configuration'); + assert.equal(issueConfig.mode ?? 'sync', 'sync'); + assert.equal(policy.include_source, false); + assert.equal(policy.include_private, false); + assert.equal(policy.include_archived, false); + assert.equal(policy.include_forks, true); +}); + +test('all PR and master changes run, PRs preview, and production runs are serialized without cancellation', () => { + const events = workflow.slice(workflow.indexOf('\non:'), workflow.indexOf('\nconcurrency:')); + assert.match(events, / pull_request:/); + assert.match(events, / push:\r?\n branches:\r?\n - master/); + assert.doesNotMatch(events, /paths:|paths-ignore:/); + assert.match(workflow, /dry_run: \$\{\{ github\.event_name == 'pull_request' \}\}/); + assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/); + assert.match(workflow, + /group: global-replicator-\$\{\{ github\.event_name == 'pull_request' && github\.event\.pull_request\.number \|\| 'delivery' \}\}/); + assert.doesNotMatch(events, /dry_run|repo_name/); +}); + +test('only the pinned new action runs and credentialed PRs retain their fork guard', () => { + assert.match(workflow, /LizardByte\/actions\/actions\/org_sync@[a-f\d]{40}/); + assert.match(workflow, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/); + assert.doesNotMatch(workflow, /pull_request_target:|contents: write|pull-requests: write|continue-on-error:/); + assert.match(workflow, /persist-credentials: false/); + assert.doesNotMatch(workflow, /derberg\/|topics_to_include|repositories-to-ignore/); + assert.equal(existsSync('.github/workflows/__file-sync-preview.yml'), false); + assert.equal(existsSync('.github/scripts/file-sync-report.mjs'), false); + assert.doesNotMatch(workflow, /uses: actions\/upload-artifact|Save synchronization report/); +}); diff --git a/tests/fixtures/org-sync-consumers.json b/tests/fixtures/org-sync-consumers.json new file mode 100644 index 00000000..838dccba --- /dev/null +++ b/tests/fixtures/org-sync-consumers.json @@ -0,0 +1,101 @@ +{ + "manual_credentials": [ + "CODECOV_TOKEN" + ], + "organizations": { + "KokoStreamTV": { + "variables": [ + "AZURE_SIGNING_ACCOUNT", + "AZURE_SIGNING_CERT_PROFILE", + "AZURE_SIGNING_ENDPOINT" + ], + "actions_secrets": [ + "APPLE_CODESIGN_IDENTITY", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD", + "APPLE_ID", + "APPLE_NOTARYTOOL_PASSWORD", + "APPLE_TEAM_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "FANART_PROJECT_API_KEY", + "GH_BOT_TOKEN", + "LRCLIB_APPLICATION_ID", + "READTHEDOCS_TOKEN", + "VIRUSTOTAL_API_KEY" + ], + "dependabot_secrets": [] + }, + "SunshineStream": { + "variables": [ + "AZURE_SIGNING_ACCOUNT", + "AZURE_SIGNING_CERT_PROFILE", + "AZURE_SIGNING_ENDPOINT", + "DOCKER_HUB_USERNAME", + "GH_BOT_NAME" + ], + "actions_secrets": [ + "APPLE_CODESIGN_IDENTITY", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD", + "APPLE_ID", + "APPLE_NOTARYTOOL_PASSWORD", + "APPLE_TEAM_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "CLOUDSMITH_API_KEY", + "COPR_BETA_WEBHOOK_TOKEN", + "COPR_CLI_CONFIG", + "COPR_STABLE_WEBHOOK_TOKEN", + "CROWDIN_TOKEN", + "DOCKER_HUB_ACCESS_TOKEN", + "DOCKER_HUB_PASSWORD", + "GH_BOT_EMAIL", + "GH_BOT_TOKEN", + "READTHEDOCS_TOKEN", + "VIRUSTOTAL_API_KEY" + ], + "dependabot_secrets": [ + "GH_BOT_EMAIL", + "GH_BOT_TOKEN" + ] + }, + "Themerr": { + "variables": [ + "AZURE_SIGNING_ACCOUNT", + "AZURE_SIGNING_CERT_PROFILE", + "AZURE_SIGNING_ENDPOINT", + "DOCKER_HUB_USERNAME", + "GH_BOT_NAME" + ], + "actions_secrets": [ + "APPLE_CODESIGN_IDENTITY", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64", + "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "DOCKER_HUB_ACCESS_TOKEN", + "DOCKER_HUB_PASSWORD", + "GH_BOT_EMAIL", + "GH_BOT_TOKEN", + "READTHEDOCS_TOKEN", + "TMDB_API_KEY_V3", + "TWITCH_CLIENT_ID", + "TWITCH_CLIENT_SECRET", + "VIRUSTOTAL_API_KEY", + "YOUTUBE_API_KEY" + ], + "dependabot_secrets": [ + "GH_BOT_EMAIL", + "GH_BOT_TOKEN", + "TMDB_API_KEY_V3", + "TWITCH_CLIENT_ID", + "TWITCH_CLIENT_SECRET", + "YOUTUBE_API_KEY" + ] + } + } +} diff --git a/tests/org-sync-policy.test.mjs b/tests/org-sync-policy.test.mjs new file mode 100644 index 00000000..2e2c942e --- /dev/null +++ b/tests/org-sync-policy.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; + +const configuration = JSON.parse(readFileSync('.github/org-sync.json', 'utf8')); +const policy = configuration.administration; +const organizations = JSON.parse(readFileSync('organizations.json', 'utf8')); +const workflow = readFileSync('.github/workflows/__global-replicator.yml', 'utf8'); +const expectedCredentials = JSON.parse(readFileSync('tests/fixtures/org-sync-consumers.json', 'utf8')); +const compareText = (left, right) => left.localeCompare(right); +const destinations = organizations.filter(org => org !== policy.source_organization); + +test('the shared policy administers destination orgs while preserving project identities', () => { + assert.equal(policy.source_organization, 'LizardByte'); + assert.equal(policy.organizations, undefined); + assert.ok(!destinations.includes(policy.source_organization)); + assert.deepEqual(policy.settings.from_source, policy.settings.from_source.toSorted(compareText)); + for (const field of ['company', 'name', 'description', 'blog']) assert.ok(!policy.settings.from_source.includes(field)); + for (const field of ['location', 'twitter_username', 'default_repository_permission', 'members_can_create_repositories']) { + assert.ok(policy.settings.from_source.includes(field)); + } + assert.equal(policy.settings.values, undefined); +}); + +test('active Actions policies, additive blocking, and both security default scopes follow the source', () => { + assert.deepEqual(policy.actions.from_source, ['permissions', 'retention', 'workflow']); + assert.deepEqual(policy.blocked_users, {from_source: true, remove_extra: false}); + assert.deepEqual(policy.security_configurations.map(config => config.source_name), ['private/internal repositories', 'strict']); + assert.ok(policy.security_configurations.every(config => config.copy_default === true)); +}); + +test('Discord hooks are selected at runtime without credential URLs or unrelated integrations', () => { + assert.deepEqual(policy.webhooks, [{id: 'discord', source_url_prefix: 'https://discord.com/api/webhooks/', unsigned: true}]); + assert.doesNotMatch(JSON.stringify(policy), /discord\.com\/api\/webhooks\/[\w-]/); + assert.ok(policy.webhooks.every(entry => entry.url === undefined && entry.source_url === undefined)); + assert.doesNotMatch(JSON.stringify(policy.webhooks), /cla/i); +}); + +test('organization credential names match destination org expectations without managing repository access', () => { + const identities = policy.secrets.flatMap(entry => entry.organizations.map(org => `${org}/${entry.kind}/${entry.name}`)); + assert.equal(new Set(identities).size, identities.length); + for (const entry of [...policy.variables, ...policy.secrets]) { + for (const field of ['visibility', 'repository_names', 'missing_repositories']) assert.equal(entry[field], undefined); + assert.ok(!entry.name.startsWith('GITHUB_')); + assert.ok(entry.organizations.length > 0); + assert.deepEqual(entry.organizations, [...new Set(entry.organizations)].toSorted(compareText)); + assert.ok(entry.organizations.every(org => destinations.includes(org))); + } + const actual = [...policy.variables.map(entry => ({...entry, kind: 'variable'})), ...policy.secrets] + .flatMap(entry => entry.organizations.map(org => `${org}:${entry.kind}:${entry.name}`)); + const expected = Object.entries(expectedCredentials.organizations).flatMap(([org, credentials]) => { + assert.ok(destinations.includes(org)); + return [['variable', credentials.variables], ['actions', credentials.actions_secrets], + ['dependabot', credentials.dependabot_secrets]] + .flatMap(([kind, names]) => names.map(name => `${org}:${kind}:${name}`)); + }); + assert.deepEqual(actual.toSorted(compareText), expected.toSorted(compareText)); +}); + +test('variables are copied by name and secrets use explicit supplied bindings, with no committed plaintext', () => { + const bindings = workflow.replaceAll(/: >-\r?\n\s+/g, ': '); + for (const entry of policy.variables) { + assert.equal(entry.from_source, entry.name); + assert.equal(entry.value, undefined); + } + for (const entry of policy.secrets) { + assert.equal(entry.value_env, entry.name); + assert.equal(entry.value, undefined); + assert.equal(entry.from_source, undefined); + assert.equal(entry.value_key, undefined); + assert.ok(bindings.includes(`${entry.value_env}: ` + '${{ secrets.' + entry.value_env + ' }}')); + } + assert.ok(!policy.secrets.some(entry => entry.name === 'ORG_SYNC_ADMIN_TOKEN')); +}); + +test('Codecov credentials are managed manually in destination orgs', () => { + assert.deepEqual(expectedCredentials.manual_credentials, ['CODECOV_TOKEN']); + assert.ok(!policy.secrets.some(entry => entry.name === 'CODECOV_TOKEN')); + assert.doesNotMatch(workflow, /CODECOV/); +}); + +test('controller-only, social, and unused credentials are absent from both the policy and its supplied bindings', () => { + const excluded = ['AUR_SSH_PUBLIC_KEY', 'AUR_SSH_PRIVATE_KEY', 'BUFFER_X_CHANNEL_ID', 'BUFFER_API_KEY', 'CLA_GIST_ID', + 'COLOR_HEX_GREEN', 'COLOR_HEX_RED', 'DISCORD_GITHUB_STATUS_CHANNEL_ID', 'DISCORD_REDDIT_CHANNEL_ID', + 'DISCORD_SPONSORS_CHANNEL_ID', 'DISCORD_USERNAME', 'DISCORD_RELEASE_WEBHOOK', 'DISCORD_WEBHOOKS', 'DISCORD_INVITE', + 'FACEBOOK_ACCESS_TOKEN', 'FACEBOOK_PAGE_ID', 'GH_PAT', 'GITLAB_SSH_PRIVATE_KEY', 'GITLAB_TOKEN', + 'GITLAB_TOKEN_ROTATOR_CLIENT_ID', 'GITLAB_TOKEN_ROTATOR_PRIVATE_KEY', 'GRAVATAR_EMAIL', 'ORG_LOGO_URL', + 'REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_PASSWORD', 'REDDIT_USERNAME', 'REDDIT_SUBREDDIT', 'REDDIT_FLAIR_ID']; + for (const name of excluded) { + assert.ok(![...policy.variables, ...policy.secrets].some(entry => entry.name === name)); + assert.doesNotMatch(workflow, new RegExp(`^ ${name}:`, 'm')); + } + assert.doesNotMatch(workflow, /^ CODECOV_TOKEN:/m); +}); + +test('one action invocation handles schemas and policy while exact-repository dispatch stays file-only', () => { + assert.equal([...workflow.matchAll(/uses: LizardByte\/actions\/actions\/org_sync@/g)].length, 1); + assert.equal([...workflow.slice(workflow.indexOf('\njobs:')).matchAll(/^ [\w-]+:\r?$/gm)].length, 1); + const folded = workflow.replaceAll(/\s+/g, ' '); + assert.ok(workflow.includes('ORG_SYNC_CONFIG: .github/org-sync.json')); + assert.ok(workflow.includes('config: ${{ env.ORG_SYNC_CONFIG }}')); + assert.equal([...workflow.matchAll(/\.github\/org-sync\.json/g)].length, 1); + for (const input of ['admin_config', 'properties_file']) { + assert.ok(folded.includes(`${input}: >- ` + + "${{ inputs.repository == '' && github.actor != 'dependabot[bot]' && env.ORG_SYNC_CONFIG || '' }}")); + } + assert.ok(workflow.includes('admin_token: ${{ secrets.ORG_SYNC_ADMIN_TOKEN || secrets.GH_BOT_TOKEN }}')); + assert.ok(workflow.includes("dry_run: ${{ github.event_name == 'pull_request' }}")); + assert.doesNotMatch(workflow, /toJSON\(secrets\)|toJson\(secrets\)|gh api|hooks\/.*\/pings/); +}); diff --git a/tests/workflow-profile-selection.test.mjs b/tests/workflow-profile-selection.test.mjs new file mode 100644 index 00000000..424bf00f --- /dev/null +++ b/tests/workflow-profile-selection.test.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import {readFileSync, readdirSync} from 'node:fs'; +import test from 'node:test'; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const propertyRoute = 'GET /repos/{owner}/{repo}/properties/values'; +const profiles = [ + ['__call-release-notifier.yml', 'release_notifications'], + ['__call-update-flathub-repo.yml', 'flathub'], + ['__call-update-homebrew-repo.yml', 'homebrew'], + ['__call-update-pacman-repo.yml', 'pacman'], + ['__call-update-winget-repo.yml', 'winget'], +]; + +function loadWorkflow(file) { + const source = readFileSync(`.github/workflows/${file}`, 'utf8').replaceAll('\r\n', '\n'); + const lines = source.split(' script: |\n')[1].split('\n'); + const body = []; + for (const line of lines) { + if (line && !line.startsWith(' ')) break; + body.push(line.slice(12)); + } + assert.ok(body.length > 0, `Missing GitHub script: ${file}`); + return {source, execute: new AsyncFunction('github', 'context', 'core', 'process', body.join('\n'))}; +} + +function profileValues(value) { + return value === undefined ? [] : [{property_name: 'workflow_profiles', value}]; +} + +for (const [file, profile] of profiles) { + const {source, execute} = loadWorkflow(file); + async function run(values, error) { + const outputs = {}; + await execute({request: async (route, coordinates) => { + assert.equal(route, propertyRoute); + assert.deepEqual(coordinates, {owner: 'project-org', repo: 'app'}); + if (error) throw error; + return {data: values}; + }}, {repo: {owner: 'project-org', repo: 'app'}}, { + info: () => {}, setOutput: (name, value) => { outputs[name] = value; }, + }, {env: {WORKFLOW_PROFILE: profile}}); + return outputs; + } + + test(`${profile} enables a matching multi-select profile independently of file-sync exclusion`, async () => { + const outputs = await run([...profileValues(['unrelated', profile]), + {property_name: 'sync_mode', value: 'excluded'}]); + assert.deepEqual(outputs, {enabled: true}); + }); + + test(`${profile} skips missing, null, empty, and unrelated profile selections`, async () => { + await Promise.all([undefined, null, [], ['other'], [`${profile}-suffix`]].map(async value => { + assert.deepEqual(await run(profileValues(value)), {enabled: false}); + })); + }); + + test(`${profile} rejects malformed multi-select values and failed API reads`, async () => { + await Promise.all([profile, 1, {}, [profile, null]].map(async value => { + await assert.rejects(run(profileValues(value)), /Invalid workflow_profiles custom property/); + })); + await assert.rejects(run([], new Error('Property access denied')), /Property access denied/); + }); + + test(`${profile} wires the configured profile to every former topic-gated step`, () => { + assert.ok(source.includes(`WORKFLOW_PROFILE: ${profile}`)); + assert.match(source, /id: check-profile/); + assert.match(source, /if:.*(?:\n\s+)?steps.check-profile.outputs.enabled == 'true'/); + assert.doesNotMatch(source, /check-label|hasTopic|getAllTopics|TOPIC:/); + if (['release_notifications', 'flathub', 'pacman'].includes(profile)) { + assert.match(source, /steps.check-release.outputs.isLatestRelease == 'true'/); + } + if (['flathub', 'homebrew', 'pacman', 'winget'].includes(profile)) { + assert.match(source, /fromJson\(steps.download.outputs.downloaded_files\)\[0\]/); + } + }); +} + +const cpm = loadWorkflow('__update-cpm-cmake.yml'); +async function cpmMatrix(repositories, valuesByName, error) { + const outputs = {}; + const github = { + rest: {repos: {listForOrg: {endpoint: {merge: options => options}}}}, + paginate: async options => { + assert.deepEqual(options, {org: 'source-org'}); + return repositories; + }, + request: async (route, {owner, repo}) => { + assert.equal(route, propertyRoute); + assert.equal(owner, 'source-org'); + if (error) throw error; + return {data: profileValues(valuesByName[repo])}; + }, + }; + const matrix = await cpm.execute(github, {repo: {owner: 'source-org'}}, { + setOutput: (name, value) => { outputs[name] = value; }, + }); + return {matrix, outputs}; +} + +test('CPM maintenance selects custom properties instead of public topics', async () => { + const repos = [{name: 'selected', topics: []}, {name: 'topic-only', topics: ['cpm']}, {name: 'unset'}]; + assert.deepEqual(await cpmMatrix(repos, {selected: ['cpm', 'cpp_style'], 'topic-only': ['cpp_style']}), { + matrix: {include: [{repo: 'selected'}]}, outputs: {'has-repositories': true}, + }); +}); + +test('CPM maintenance reports empty selections so both matrix jobs can skip safely', async () => { + assert.deepEqual(await cpmMatrix([{name: 'unset'}, {name: 'null'}], {null: null}), { + matrix: {include: []}, outputs: {'has-repositories': false}, + }); + assert.deepEqual(await cpmMatrix([], {}), { + matrix: {include: []}, outputs: {'has-repositories': false}, + }); + assert.equal(cpm.source.match(/if:.*needs.setup-matrix.outputs.has-repositories == 'true'/g).length, 2); +}); + +test('CPM maintenance fails for malformed selections and inaccessible properties', async () => { + await assert.rejects(cpmMatrix([{name: 'bad', full_name: 'source-org/bad'}], {bad: 'cpm'}), + /Invalid workflow_profiles custom property for source-org\/bad/); + await assert.rejects(cpmMatrix([{name: 'bad'}], {}, new Error('Access denied')), /Access denied/); +}); + +test('workflow automation contains no repository-topic API calls or legacy profile guidance', () => { + for (const file of readdirSync('.github/workflows').filter(file => file.endsWith('.yml'))) { + const source = readFileSync(`.github/workflows/${file}`, 'utf8'); + assert.doesNotMatch(source, /getAllTopics|repository\.topics|repository (?:topic|label)/, file); + } +});