diff --git a/README.md b/README.md
index 1aa6692..fb64d6d 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Please refer to the [release page](https://github.com/joshjohanning/bulk-github-
## Features
- 🏷️ Sync custom property definitions across organizations
+- 🏷️ Sync custom property values onto selected organization repositories
- 📋 Sync organization-level rulesets across organizations
- 🏷️ Sync issue type definitions across organizations
- 🧩 Sync issue field definitions across organizations
@@ -73,7 +74,7 @@ For stronger security and higher rate limits, use a GitHub App:
**Organization permissions:**
- **Administration**: Read and write (required for managing organization settings, rulesets, and organization role team assignments)
- - **Custom properties**: Admin (required for managing custom property definitions)
+ - **Custom properties**: Admin (required for managing custom property definitions) or Write (required for managing repository custom property values)
- **Custom organization roles**: Write (required for managing custom organization roles)
- **Custom repository roles**: Write (required for managing custom repository roles)
- **Issue types**: Write (required for managing issue type definitions)
@@ -191,7 +192,7 @@ orgs:
**Optional: `base-path`**
-Use the `base-path` top-level property to avoid repeating a common directory prefix for all file-path settings (`custom-properties-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`). Relative paths in per-org overrides are resolved relative to `base-path`. Absolute paths are left unchanged.
+Use the `base-path` top-level property to avoid repeating a common directory prefix for all file-path settings (`custom-properties-file`, `custom-property-values-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`). Relative paths in per-org overrides are resolved relative to `base-path`. Absolute paths are left unchanged.
```yaml
base-path: './config/'
@@ -263,20 +264,21 @@ The file-based form lets you keep per-org config in separate files while still u
### Supported features
-| Feature | Inline key | File-path key | Per-org semantics |
-| ---------------------------------- | ------------------------------------ | ----------------------------------------- | -------------------------- |
-| Custom properties | `custom-properties` | `custom-properties-file` | Merge by `name` with base |
-| Issue types | `issue-types` | `issue-types-file` | Merge by `name` with base |
-| Issue fields | `issue-fields` | `issue-fields-file` | Merge by `name` with base |
-| Custom organization roles | `custom-org-roles` | `custom-org-roles-file` | Merge by `name` with base |
-| Custom repository roles | `custom-repo-roles` | `custom-repo-roles-file` | Merge by `name` with base |
-| Code security configurations | `code-security-configurations` | `code-security-configurations-file` | Merge by `name` with base |
-| Organization role team assignments | `organization-role-team-assignments` | `organization-role-team-assignments-file` | Replaces base for that org |
-| Member privileges | `member-privileges` | _(direct action inputs serve as base)_ | Per-key override of base |
-| Organization profile | `org-profile` | _(direct action inputs serve as base)_ | Per-key override of base |
-| Actions policy | `actions-policy` | _(direct action inputs serve as base)_ | Per-key override of base |
-| Rulesets | _(file only — no inline form)_ | `rulesets-file` (string or YAML array) | Replaces base for that org |
-| Actions allow list | _(file only — no inline form)_ | `actions-allow-list-file` | Replaces base for that org |
+| Feature | Inline key | File-path key | Per-org semantics |
+| ---------------------------------- | ------------------------------------ | ----------------------------------------- | ---------------------------------- |
+| Custom properties | `custom-properties` | `custom-properties-file` | Merge by `name` with base |
+| Custom property values | `custom-property-values` | `custom-property-values-file` | File replaces base; inline appends |
+| Issue types | `issue-types` | `issue-types-file` | Merge by `name` with base |
+| Issue fields | `issue-fields` | `issue-fields-file` | Merge by `name` with base |
+| Custom organization roles | `custom-org-roles` | `custom-org-roles-file` | Merge by `name` with base |
+| Custom repository roles | `custom-repo-roles` | `custom-repo-roles-file` | Merge by `name` with base |
+| Code security configurations | `code-security-configurations` | `code-security-configurations-file` | Merge by `name` with base |
+| Organization role team assignments | `organization-role-team-assignments` | `organization-role-team-assignments-file` | Replaces base for that org |
+| Member privileges | `member-privileges` | _(direct action inputs serve as base)_ | Per-key override of base |
+| Organization profile | `org-profile` | _(direct action inputs serve as base)_ | Per-key override of base |
+| Actions policy | `actions-policy` | _(direct action inputs serve as base)_ | Per-key override of base |
+| Rulesets | _(file only — no inline form)_ | `rulesets-file` (string or YAML array) | Replaces base for that org |
+| Actions allow list | _(file only — no inline form)_ | `actions-allow-list-file` | Replaces base for that org |
### Precedence
@@ -290,10 +292,12 @@ When both inline and file-based per-org overrides are set for the same org:
- For **replace-semantics** features (currently `organization-role-team-assignments`), inline takes precedence and the per-org file is ignored.
- For **merge-by-name** features (`custom-properties`, `issue-types`, `issue-fields`, `custom-org-roles`, `custom-repo-roles`, `code-security-configurations`), the per-org file becomes that org's base and inline entries then merge on top by `name`.
+- For **custom property values**, the per-org file replaces the base values file for that org, and inline `custom-property-values` rules append after file rules. Later rules win if they set the same property on the same repository.
### Merge vs replace
- **Merge by `name`** — per-org list items with the same `name` override the base item; other base items are preserved.
+- **File replaces base; inline appends** — the per-org file replaces the base file, then inline per-org rules are appended and can override earlier rules by order.
- **Replaces base for that org** — the entire per-org value replaces the base (no merge).
- **Per-key override of base** — keys present in the per-org block override base values for those keys only; other base keys are preserved.
@@ -429,6 +433,101 @@ By default, syncing custom properties will create or update the specified proper
---
+## Syncing Custom Property Values
+
+Sync custom property values onto repositories in each organization. This assigns values for properties that already exist in the org schema, such as setting `team=platform` on selected repositories.
+
+> [!TIP]
+> 📄 **See full example:** [sample-configuration/custom-property-values.yml](sample-configuration/custom-property-values.yml)
+
+Create a `custom-property-values.yml` file:
+
+```yaml
+- repositories:
+ names:
+ - api
+ - web
+ names-file: teams/platform-repos.yml
+ query: 'topic:platform archived:false'
+ properties:
+ team: platform
+ environment:
+ - production
+ - staging
+
+- repositories:
+ names-file: teams/infra-repos.yml
+ properties:
+ team: infrastructure
+```
+
+Use it in a workflow:
+
+```yml
+- name: Sync Organization Settings
+ uses: joshjohanning/bulk-github-org-settings-sync-action@v1
+ with:
+ github-token: ${{ secrets.ORG_ADMIN_TOKEN }}
+ organizations: 'my-org'
+ custom-properties-file: './custom-properties.yml'
+ custom-property-values-file: './custom-property-values.yml'
+ dry-run: ${{ github.event_name == 'pull_request' }}
+```
+
+**Repository selectors:**
+
+| Selector | Description |
+| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `repositories.names` | Explicit bare repository names in the current org. `org/repo` is rejected because the action already runs per org and the API accepts bare names. |
+| `repositories.names-file` | YAML file containing a list of bare repository names. Paths are resolved relative to the custom property values file that references them. Inline `orgs.yml` rules resolve relative to `base-path`, or the `orgs.yml` directory when `base-path` is not set. |
+| `repositories.query` | GitHub repository query passed to `GET /orgs/{org}/properties/values` as `repository_query`. |
+
+Selectors in one rule are unioned. Missing repositories from `names` or `names-file` warn and are skipped. Query selectors that match zero repositories warn and continue.
+
+`names-file` is useful when a team owns a repo list through CODEOWNERS and normal pull request review:
+
+```yaml
+# teams/platform-repos.yml
+- api
+- web
+- worker
+```
+
+> [!IMPORTANT]
+> CODEOWNERS provides review and audit workflow, not an authorization boundary. The token running this action can still apply values to any repository it can manage.
+
+**Conflict and update behavior:**
+
+- Rules are resolved before any writes happen.
+- If multiple rules set the same property on the same repo, later rules win and a warning is logged.
+- If multiple rules set different properties on the same repo, the values are merged.
+- Existing unmanaged values are left alone. To unset a value, set that property to `null` explicitly.
+- `multi_select` values are compared order-insensitively to avoid unnecessary updates.
+- Updates are batched in groups of up to 30 repositories, matching GitHub's API limit.
+
+> [!NOTE]
+> Query selectors depend on GitHub repository search behavior and can be affected by indexing latency or result limits. Prefer `names` or `names-file` for large or freshness-sensitive selections.
+
+### Per-Org Custom Property Value Overrides
+
+In `orgs.yml`, use `custom-property-values-file` to replace the base values file for a specific org, and `custom-property-values` to append org-specific rules after file rules:
+
+```yaml
+orgs:
+ - org: my-org
+ # inherits base custom-property-values-file as-is
+
+ - org: my-other-org
+ custom-property-values-file: './config/custom-property-values/other-org.yml'
+ custom-property-values:
+ - repositories:
+ names: [special-service]
+ properties:
+ team: platform
+```
+
+---
+
## Syncing Organization Rulesets
Sync organization-level rulesets across organizations. Rulesets define rules that apply to repositories within the organization (e.g., branch protection rules, tag rules). Each ruleset is defined in its own JSON file, and `rulesets-file` accepts comma-separated paths to sync multiple rulesets.
@@ -1354,6 +1453,7 @@ orgs:
| `organizations` | Comma-separated list of organization names | No | |
| `organizations-file` | Path to YAML file containing organization settings configuration | No | |
| `custom-properties-file` | Path to a YAML file defining custom property schemas | No | |
+| `custom-property-values-file` | Path to a YAML file defining custom property values for selected organization repositories | No | |
| `delete-unmanaged-properties` | Delete custom properties not defined in the configuration file | No | `false` |
| `issue-types-file` | Path to a YAML file defining issue type definitions | No | |
| `delete-unmanaged-issue-types` | Delete issue types not defined in the configuration file | No | `false` |
@@ -1410,7 +1510,7 @@ orgs:
| `dry-run` | Preview changes without applying them | No | `false` |
> [!NOTE]
-> You must provide either `organizations` or `organizations-file`. The `custom-properties-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`, `custom-org-roles-file`, `custom-repo-roles-file`, `actions-allow-list-file`, `code-security-configurations-file`, and `organization-role-team-assignments-file` inputs provide base settings for all orgs and can be combined with either approach. Member privilege settings can be provided as individual inputs (e.g., `default-repository-permission`). Actions policy settings can be provided as individual inputs (e.g., `actions-policy-allowed-actions`). Org profile settings can be provided as individual inputs (e.g., `org-name`). The `dot-github-source-dir` and `dot-github-private-source-dir` inputs sync a local directory to the respective special repositories via PR, and `create-missing-dot-github-repos` plus the repo-visibility inputs control optional repo bootstrapping before sync. Per-org overrides in `organizations-file` layer on top of the base unless otherwise noted — see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for which features support inline vs file-path overrides and their precedence and merge semantics.
+> You must provide either `organizations` or `organizations-file`. The `custom-properties-file`, `custom-property-values-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`, `custom-org-roles-file`, `custom-repo-roles-file`, `actions-allow-list-file`, `code-security-configurations-file`, and `organization-role-team-assignments-file` inputs provide base settings for all orgs and can be combined with either approach. Member privilege settings can be provided as individual inputs (e.g., `default-repository-permission`). Actions policy settings can be provided as individual inputs (e.g., `actions-policy-allowed-actions`). Org profile settings can be provided as individual inputs (e.g., `org-name`). The `dot-github-source-dir` and `dot-github-private-source-dir` inputs sync a local directory to the respective special repositories via PR, and `create-missing-dot-github-repos` plus the repo-visibility inputs control optional repo bootstrapping before sync. Per-org overrides in `organizations-file` layer on top of the base unless otherwise noted — see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for which features support inline vs file-path overrides and their precedence and merge semantics.
## Action Outputs
diff --git a/__tests__/index.test.js b/__tests__/index.test.js
index 247d795..ecbd55e 100644
--- a/__tests__/index.test.js
+++ b/__tests__/index.test.js
@@ -37,6 +37,8 @@ inputs:
description: 'Path to YAML file'
custom-properties-file:
description: 'Custom properties file'
+ custom-property-values-file:
+ description: 'Custom property values file'
delete-unmanaged-properties:
description: 'Delete unmanaged properties'
issue-types-file:
@@ -207,9 +209,12 @@ const {
parseOrganizations,
parseOrganizationsFile,
parseCustomPropertiesFile,
+ parseCustomPropertyValuesFile,
+ normalizeCustomPropertyValueRules,
normalizeCustomProperties,
compareCustomProperty,
syncCustomProperties,
+ syncCustomPropertyValues,
parseIssueTypesFile,
normalizeIssueTypes,
compareIssueType,
@@ -1508,6 +1513,65 @@ orgs:
});
});
+ // ─── parseCustomPropertyValuesFile ───────────────────────────────────────
+
+ describe('parseCustomPropertyValuesFile', () => {
+ test('should parse custom property value rules and resolve names-file relative to values file', () => {
+ const valuesYaml = `- repositories:
+ names: [api, web]
+ names-file: teams/platform.yml
+ query: 'topic:platform archived:false'
+ properties:
+ team: platform
+ environments: [production, staging]
+ owner: null
+`;
+ setMockFileContent(valuesYaml, '/mock/config/custom-property-values.yml');
+
+ const result = parseCustomPropertyValuesFile('/mock/config/custom-property-values.yml');
+
+ expect(result).toEqual([
+ {
+ repositories: {
+ names: ['api', 'web'],
+ namesFile: '/mock/config/teams/platform.yml',
+ query: 'topic:platform archived:false'
+ },
+ properties: [
+ { property_name: 'team', value: 'platform' },
+ { property_name: 'environments', value: ['production', 'staging'] },
+ { property_name: 'owner', value: null }
+ ]
+ }
+ ]);
+ });
+
+ test('should reject owner/name repository identifiers', () => {
+ expect(() =>
+ normalizeCustomPropertyValueRules(
+ [
+ {
+ repositories: { names: ['my-org/api'] },
+ properties: { team: 'platform' }
+ }
+ ],
+ 'custom property values'
+ )
+ ).toThrow('bare repository name');
+ });
+
+ test('should preserve boolean values for true_false properties', () => {
+ const result = normalizeCustomPropertyValueRules(
+ [{ repositories: { names: ['api'] }, properties: { 'is-public': true, 'is-archived': false } }],
+ 'custom property values'
+ );
+ const prop1 = result[0].properties.find(p => p.property_name === 'is-public');
+ const prop2 = result[0].properties.find(p => p.property_name === 'is-archived');
+ expect(prop1.value).toBe(true);
+ expect(prop2.value).toBe(false);
+ });
+ });
+
// ─── mergeCustomProperties ───────────────────────────────────────────
describe('mergeCustomProperties', () => {
@@ -2484,6 +2548,216 @@ orgs:
});
});
+ // ─── syncCustomPropertyValues ────────────────────────────────────────────
+
+ describe('syncCustomPropertyValues', () => {
+ const valueRules = [
+ {
+ repositories: {
+ names: ['api', 'missing'],
+ query: 'topic:platform archived:false'
+ },
+ properties: [
+ { property_name: 'team', value: 'platform' },
+ { property_name: 'environments', value: ['production', 'staging'] }
+ ]
+ }
+ ];
+
+ function mockCustomPropertyValueFetches() {
+ mockPaginate.mockImplementation((route, params) => {
+ if (route === 'GET /orgs/{org}/properties/values' && params.repository_query) {
+ return Promise.resolve([{ repository_name: 'web', properties: [] }]);
+ }
+ if (route === 'GET /orgs/{org}/properties/values') {
+ return Promise.resolve([
+ {
+ repository_name: 'api',
+ properties: [
+ { property_name: 'team', value: 'frontend' },
+ { property_name: 'environments', value: ['staging', 'production'] }
+ ]
+ },
+ { repository_name: 'web', properties: [] }
+ ]);
+ }
+ return Promise.resolve([]);
+ });
+
+ mockRequest.mockResolvedValueOnce({
+ data: [
+ {
+ property_name: 'team',
+ value_type: 'single_select',
+ required: false,
+ allowed_values: ['platform', 'frontend']
+ },
+ {
+ property_name: 'environments',
+ value_type: 'multi_select',
+ required: false,
+ allowed_values: ['production', 'staging']
+ }
+ ]
+ });
+ }
+
+ test('should diff values, warn on missing hard-selected repos, and patch changed repos', async () => {
+ mockCustomPropertyValueFetches();
+ // fallback GET /repos/{owner}/{repo} for 'missing' (not in /properties/values)
+ mockRequest.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 }));
+ mockRequest.mockResolvedValueOnce({});
+
+ const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, false);
+
+ expect(result.failed).toBe(false);
+ expect(result.subResults.some(r => r.kind === 'custom-property-value-select')).toBe(true);
+ expect(result.subResults.filter(r => r.kind === 'custom-property-value-update')).toHaveLength(2);
+ expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', {
+ org: 'my-org',
+ repository_names: ['api'],
+ properties: [{ property_name: 'team', value: 'platform' }]
+ });
+ expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', {
+ org: 'my-org',
+ repository_names: ['web'],
+ properties: [
+ { property_name: 'environments', value: ['production', 'staging'] },
+ { property_name: 'team', value: 'platform' }
+ ]
+ });
+ });
+
+ test('should not patch in dry-run mode', async () => {
+ mockCustomPropertyValueFetches();
+ // fallback GET /repos/{owner}/{repo} for 'missing' (not in /properties/values)
+ mockRequest.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 }));
+
+ const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, true);
+
+ expect(result.subResults.some(r => r.kind === 'custom-property-value-update')).toBe(true);
+ expect(mockRequest).toHaveBeenCalledTimes(2); // schema fetch + fallback GET
+ });
+
+ test('should warn on conflicting rule values and let later rules win', async () => {
+ mockPaginate.mockImplementation(route => {
+ if (route === 'GET /orgs/{org}/properties/values') {
+ return Promise.resolve([
+ { repository_name: 'api', properties: [{ property_name: 'team', value: 'frontend' }] }
+ ]);
+ }
+ return Promise.resolve([]);
+ });
+ mockRequest
+ .mockResolvedValueOnce({
+ data: [
+ {
+ property_name: 'team',
+ value_type: 'single_select',
+ required: false,
+ allowed_values: ['platform', 'frontend']
+ }
+ ]
+ })
+ .mockResolvedValueOnce({});
+
+ const result = await syncCustomPropertyValues(
+ mockOctokit,
+ 'my-org',
+ [
+ { repositories: { names: ['api'] }, properties: [{ property_name: 'team', value: 'frontend' }] },
+ { repositories: { names: ['api'] }, properties: [{ property_name: 'team', value: 'platform' }] }
+ ],
+ false
+ );
+
+ expect(result.subResults.some(r => r.kind === 'custom-property-value-conflict')).toBe(true);
+ // conflict warning should name both rule numbers and values
+ const conflictResult = result.subResults.find(r => r.kind === 'custom-property-value-conflict');
+ expect(conflictResult.message).toMatch(/rule 1/);
+ expect(conflictResult.message).toMatch(/rule 2/);
+ expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', {
+ org: 'my-org',
+ repository_names: ['api'],
+ properties: [{ property_name: 'team', value: 'platform' }]
+ });
+ });
+
+ test('should resolve empty array as equal to null to prevent infinite diff', async () => {
+ mockPaginate.mockImplementation(route => {
+ if (route === 'GET /orgs/{org}/properties/values') {
+ return Promise.resolve([
+ {
+ repository_name: 'api',
+ properties: [{ property_name: 'environments', value: null }]
+ }
+ ]);
+ }
+ return Promise.resolve([]);
+ });
+ mockRequest.mockResolvedValueOnce({
+ data: [{ property_name: 'environments', value_type: 'multi_select', required: false, allowed_values: [] }]
+ });
+
+ // desired = [] (empty array), current = null — should be treated as equal
+ const result = await syncCustomPropertyValues(
+ mockOctokit,
+ 'my-org',
+ [{ repositories: { names: ['api'] }, properties: [{ property_name: 'environments', value: [] }] }],
+ false
+ );
+
+ expect(result.failed).toBe(false);
+ expect(result.subResults.filter(r => r.kind === 'custom-property-value-update')).toHaveLength(0);
+ // PATCH should NOT be called since [] == null
+ expect(mockRequest).toHaveBeenCalledTimes(1); // schema only
+ });
+
+ test('should merge desired schema in dry-run to avoid false unknown-property errors', async () => {
+ mockPaginate.mockImplementation(route => {
+ if (route === 'GET /orgs/{org}/properties/values') {
+ return Promise.resolve([{ repository_name: 'api', properties: [] }]);
+ }
+ return Promise.resolve([]);
+ });
+ // schema does NOT contain 'new-prop' yet
+ mockRequest.mockResolvedValueOnce({ data: [] });
+
+ const desiredProperties = [{ property_name: 'new-prop', value_type: 'string', required: false }];
+
+ await expect(
+ syncCustomPropertyValues(
+ mockOctokit,
+ 'my-org',
+ [{ repositories: { names: ['api'] }, properties: [{ property_name: 'new-prop', value: 'hello' }] }],
+ true, // dry-run
+ desiredProperties
+ )
+ ).resolves.not.toThrow();
+ });
+
+ test('should throw on schema validation failure when true_false property has non-boolean value', async () => {
+ mockPaginate.mockImplementation(route => {
+ if (route === 'GET /orgs/{org}/properties/values') {
+ return Promise.resolve([{ repository_name: 'api', properties: [] }]);
+ }
+ return Promise.resolve([]);
+ });
+ mockRequest.mockResolvedValueOnce({
+ data: [{ property_name: 'is-public', value_type: 'true_false', required: false }]
+ });
+
+ await expect(
+ syncCustomPropertyValues(
+ mockOctokit,
+ 'my-org',
+ [{ repositories: { names: ['api'] }, properties: [{ property_name: 'is-public', value: 'yes' }] }],
+ false
+ )
+ ).rejects.toThrow('must be a boolean');
+ });
+ });
+
// ─── run (integration) ─────────────────────────────────────────────────
describe('Action execution', () => {
diff --git a/action.yml b/action.yml
index 071e79b..64d47bb 100644
--- a/action.yml
+++ b/action.yml
@@ -27,6 +27,9 @@ inputs:
custom-properties-file:
description: 'Path to a YAML file defining custom property schemas to sync to target organizations'
required: false
+ custom-property-values-file:
+ description: 'Path to a YAML file defining custom property values to sync to target organization repositories'
+ required: false
delete-unmanaged-properties:
description: 'Delete custom properties not defined in the configuration file'
required: false
diff --git a/badges/coverage.svg b/badges/coverage.svg
index c9dff47..76b59bb 100644
--- a/badges/coverage.svg
+++ b/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index ea9cd17..10db6ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulk-github-org-settings-sync-action",
- "version": "1.12.0",
+ "version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulk-github-org-settings-sync-action",
- "version": "1.12.0",
+ "version": "1.13.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.1",
diff --git a/package.json b/package.json
index 838e835..6afd8a3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "bulk-github-org-settings-sync-action",
"description": "🏢 Bulk configure GitHub organization settings across multiple orgs using a declarative YAML config",
- "version": "1.12.0",
+ "version": "1.13.0",
"type": "module",
"author": {
"name": "Josh Johanning",
diff --git a/sample-configuration/custom-property-values.yml b/sample-configuration/custom-property-values.yml
new file mode 100644
index 0000000..e602e82
--- /dev/null
+++ b/sample-configuration/custom-property-values.yml
@@ -0,0 +1,24 @@
+# Standalone custom property values file for use with custom-property-values-file input
+# Use with: custom-property-values-file: './custom-property-values.yml'
+#
+# Properties must already exist in the organization custom property schema.
+# Pair this with custom-properties-file when you want this action to manage both
+# the schema and selected repository values.
+
+- repositories:
+ names:
+ - api
+ - web
+ names-file: teams/platform-repos.yml
+ query: 'topic:platform archived:false'
+ properties:
+ team: platform
+ environment:
+ - production
+ - staging
+
+- repositories:
+ names-file: teams/infra-repos.yml
+ properties:
+ team: devops
+ cost-center: CC-1234
diff --git a/sample-configuration/orgs.yml b/sample-configuration/orgs.yml
index 4486dab..be75499 100644
--- a/sample-configuration/orgs.yml
+++ b/sample-configuration/orgs.yml
@@ -13,6 +13,8 @@
# merge with the base by `name`; some replace the base for that org. Two
# features are file-only (no inline form): `rulesets-file` and
# `actions-allow-list-file`.
+# Custom property values are ordered rules: per-org files replace the base file,
+# and inline rules append after file rules so later rules can override earlier ones.
#
# See the README "Per-Org Overrides: Inline vs File-Based" section for the
# full feature matrix, precedence rules, and merge vs replace semantics:
@@ -48,6 +50,11 @@ orgs:
- backend
- data-science
values-editable-by: org_actors
+ custom-property-values:
+ - repositories:
+ names: [special-service]
+ properties:
+ team: platform
issue-types:
- name: Bug
description: 'Something is broken'
@@ -121,6 +128,7 @@ orgs:
# Org with per-org FILE-BASED overrides (point to a different config file for this org)
- org: my-file-based-org
custom-properties-file: './config/custom-properties/file-based-org.yml'
+ custom-property-values-file: './config/custom-property-values/file-based-org.yml'
issue-types-file: './config/issue-types/file-based-org.yml'
issue-fields-file: './config/issue-fields/file-based-org.yml'
custom-org-roles-file: './config/custom-org-roles/file-based-org.yml'
diff --git a/sample-configuration/teams/platform-repos.yml b/sample-configuration/teams/platform-repos.yml
new file mode 100644
index 0000000..1f8c303
--- /dev/null
+++ b/sample-configuration/teams/platform-repos.yml
@@ -0,0 +1,6 @@
+# Repo list that can be owned by a team via CODEOWNERS / PR review.
+# CODEOWNERS is a review workflow, not an authorization boundary.
+
+- api
+- web
+- worker
diff --git a/src/index.js b/src/index.js
index b35c82a..eeedc16 100644
--- a/src/index.js
+++ b/src/index.js
@@ -125,6 +125,13 @@ const KNOWN_CUSTOM_PROPERTY_KEYS = new Set([
'values_editable_by'
]);
+/**
+ * Known keys for custom property value rules in the YAML file.
+ * Used to warn about typos or unknown keys.
+ */
+const KNOWN_CUSTOM_PROPERTY_VALUE_RULE_KEYS = new Set(['repositories', 'properties']);
+const KNOWN_CUSTOM_PROPERTY_VALUE_REPOSITORIES_KEYS = new Set(['names', 'names-file', 'query']);
+
/**
* Known keys for issue type definitions in the YAML file.
* Used to warn about typos or unknown keys.
@@ -253,6 +260,8 @@ export const ACTIONS_POLICY_SETTINGS = new Map([
const ORG_CONFIG_TOP_LEVEL_KEYS = new Set([
'org',
'custom-properties',
+ 'custom-property-values',
+ 'custom-property-values-file',
'custom-properties-file',
'delete-unmanaged-properties',
'issue-types',
@@ -350,6 +359,31 @@ export function validateOrgConfig(orgConfig, orgName) {
}
}
+ if (Array.isArray(orgConfig['custom-property-values'])) {
+ for (const [index, rule] of orgConfig['custom-property-values'].entries()) {
+ if (typeof rule !== 'object' || rule === null) continue;
+ for (const key of Object.keys(rule)) {
+ if (!KNOWN_CUSTOM_PROPERTY_VALUE_RULE_KEYS.has(key)) {
+ core.warning(
+ `⚠️ Unknown custom property value rule key "${key}" found for rule ${index + 1} in organization "${orgName}". ` +
+ `This key may not exist or may have a typo.`
+ );
+ }
+ }
+
+ if (typeof rule.repositories === 'object' && rule.repositories !== null) {
+ for (const key of Object.keys(rule.repositories)) {
+ if (!KNOWN_CUSTOM_PROPERTY_VALUE_REPOSITORIES_KEYS.has(key)) {
+ core.warning(
+ `⚠️ Unknown custom property value repository selector key "${key}" found for rule ${index + 1} in organization "${orgName}". ` +
+ `This key may not exist or may have a typo.`
+ );
+ }
+ }
+ }
+ }
+ }
+
// Validate issue type keys if present
if (Array.isArray(orgConfig['issue-types'])) {
for (const issueType of orgConfig['issue-types']) {
@@ -487,6 +521,7 @@ export function validateOrgConfig(orgConfig, orgName) {
*/
const FILE_PATH_CONFIG_KEYS = [
'custom-properties-file',
+ 'custom-property-values-file',
'issue-types-file',
'issue-fields-file',
'rulesets-file',
@@ -575,6 +610,10 @@ const SYNC_KIND_LABELS = Object.freeze({
'custom-property-update': 'custom property (updated)',
'custom-property-delete': 'custom property (deleted)',
'custom-property-fetch': 'custom property (fetch failed)',
+ 'custom-property-value-update': 'custom property value (updated)',
+ 'custom-property-value-select': 'custom property value (selection warning)',
+ 'custom-property-value-conflict': 'custom property value (conflict)',
+ 'custom-property-value-fetch': 'custom property value (fetch failed)',
'issue-type-create': 'issue type (created)',
'issue-type-update': 'issue type (updated)',
'issue-type-delete': 'issue type (deleted)',
@@ -794,7 +833,8 @@ function formatSubResultStatus(status) {
* @param {string} [dotGithubRepoVisibility] - Visibility used when creating the .github repo
* @param {string} [dotGithubPrivateRepoVisibility] - Visibility used when creating the .github-private repo
* @param {string} [issueFieldsFile] - Path to issue fields YAML file (base for all orgs)
- * @returns {Array<{ org: string, customProperties?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, issueFields?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string, createMissingDotGithubRepos?: boolean, dotGithubRepoVisibility?: string, dotGithubPrivateRepoVisibility?: string }>} Parsed org configs
+ * @param {string} [customPropertyValuesFile] - Path to custom property values YAML file (base for all orgs)
+ * @returns {Array<{ org: string, customProperties?: Array, customPropertyValues?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, issueFields?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string, createMissingDotGithubRepos?: boolean, dotGithubRepoVisibility?: string, dotGithubPrivateRepoVisibility?: string }>} Parsed org configs
*/
export function parseOrganizations(
organizationsInput,
@@ -816,7 +856,8 @@ export function parseOrganizations(
createMissingDotGithubRepos,
dotGithubRepoVisibility,
dotGithubPrivateRepoVisibility,
- issueFieldsFile
+ issueFieldsFile,
+ customPropertyValuesFile
) {
if (
issueFieldsFile === undefined &&
@@ -851,6 +892,11 @@ export function parseOrganizations(
baseCustomProperties = parseCustomPropertiesFile(customPropertiesFile);
}
+ let baseCustomPropertyValues = null;
+ if (customPropertyValuesFile) {
+ baseCustomPropertyValues = parseCustomPropertyValuesFile(customPropertyValuesFile);
+ }
+
// Load base issue types from separate file (applies to all orgs)
let baseIssueTypes = null;
if (issueTypesFile) {
@@ -935,6 +981,26 @@ export function parseOrganizations(
// Clean up the intermediate field
delete orgConfig.customPropertiesFile;
+ let orgCustomPropertyValuesBase = baseCustomPropertyValues;
+ if (orgConfig.customPropertyValuesFile) {
+ try {
+ orgCustomPropertyValuesBase = parseCustomPropertyValuesFile(orgConfig.customPropertyValuesFile);
+ } catch (error) {
+ throw new Error(
+ `Failed to parse custom property values file "${orgConfig.customPropertyValuesFile}" for organization "${orgConfig.org}": ${error.message}`,
+ { cause: error }
+ );
+ }
+ }
+
+ if (orgCustomPropertyValuesBase || orgConfig.customPropertyValues) {
+ orgConfig.customPropertyValues = [
+ ...(orgCustomPropertyValuesBase || []),
+ ...(orgConfig.customPropertyValues || [])
+ ];
+ }
+ delete orgConfig.customPropertyValuesFile;
+
// Per-org issue-types-file overrides the base for this org
let orgIssueTypesBase = baseIssueTypes;
if (orgConfig.issueTypesFile) {
@@ -1155,6 +1221,7 @@ export function parseOrganizations(
return orgs.map(org => ({
org,
...(baseCustomProperties ? { customProperties: baseCustomProperties } : {}),
+ ...(baseCustomPropertyValues ? { customPropertyValues: baseCustomPropertyValues } : {}),
...(baseIssueTypes ? { issueTypes: baseIssueTypes } : {}),
...(baseIssueFields ? { issueFields: baseIssueFields } : {}),
...(rulesetsFiles && rulesetsFiles.length > 0 ? { rulesetsFiles } : {}),
@@ -1731,7 +1798,7 @@ export function mergeOrgProfile(baseProfile, orgProfile) {
/**
* Parse the organizations YAML config file.
* @param {string} filePath - Path to the YAML file
- * @returns {Array<{ org: string, customPropertiesFile?: string, customProperties?: Array, issueTypesFile?: string, issueTypes?: Array, issueFieldsFile?: string, issueFields?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, deleteUnmanagedProperties?: boolean, deleteUnmanagedIssueTypes?: boolean, deleteUnmanagedIssueFields?: boolean, memberPrivileges?: Object, orgProfile?: Object, codeSecurityConfigurationsFile?: string, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowListFile?: string, organizationRoleTeamAssignments?: Array, organizationRoleTeamAssignmentsFile?: string }>}
+ * @returns {Array<{ org: string, customPropertiesFile?: string, customProperties?: Array, customPropertyValuesFile?: string, customPropertyValues?: Array, issueTypesFile?: string, issueTypes?: Array, issueFieldsFile?: string, issueFields?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, deleteUnmanagedProperties?: boolean, deleteUnmanagedIssueTypes?: boolean, deleteUnmanagedIssueFields?: boolean, memberPrivileges?: Object, orgProfile?: Object, codeSecurityConfigurationsFile?: string, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowListFile?: string, organizationRoleTeamAssignments?: Array, organizationRoleTeamAssignmentsFile?: string }>}
*/
export function parseOrganizationsFile(filePath) {
if (!fs.existsSync(filePath)) {
@@ -1778,6 +1845,25 @@ export function parseOrganizationsFile(filePath) {
result.customProperties = normalizeCustomProperties(orgConfig['custom-properties']);
}
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'custom-property-values-file')) {
+ const cpvFile = orgConfig['custom-property-values-file'];
+ if (typeof cpvFile !== 'string' || cpvFile.trim() === '') {
+ throw new Error(
+ `Invalid "custom-property-values-file" for org "${orgConfig.org}": expected a non-empty string`
+ );
+ }
+ result.customPropertyValuesFile = cpvFile.trim();
+ }
+
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'custom-property-values')) {
+ const inlineBaseDir = basePath ? basePath : path.dirname(filePath);
+ result.customPropertyValues = normalizeCustomPropertyValueRules(
+ orgConfig['custom-property-values'],
+ `custom-property-values for org "${orgConfig.org}"`,
+ inlineBaseDir
+ );
+ }
+
if (Object.prototype.hasOwnProperty.call(orgConfig, 'issue-types-file')) {
const itFile = orgConfig['issue-types-file'];
if (typeof itFile !== 'string' || itFile.trim() === '') {
@@ -2067,6 +2153,169 @@ export function parseCustomPropertiesFile(filePath) {
return normalizeCustomProperties(properties);
}
+/**
+ * Parse a standalone custom property values YAML file.
+ * @param {string} filePath - Path to the YAML file
+ * @returns {Array