@grafana/create-plugin 7.4.0-canary.2630.25780630776.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ # v7.4.0 (Wed May 13 2026)
2
+
3
+ #### 🚀 Enhancement
4
+
5
+ - feat: harden bundle-stats workflow permissions [#2627](https://github.com/grafana/plugin-tools/pull/2627) ([@jackw](https://github.com/jackw))
6
+
7
+ #### Authors: 1
8
+
9
+ - Jack Westbrook ([@jackw](https://github.com/jackw))
10
+
11
+ ---
12
+
1
13
  # v7.3.1 (Wed May 06 2026)
2
14
 
3
15
  #### 🐛 Bug Fix
@@ -42,6 +42,12 @@ var defaultMigrations = [
42
42
  version: "6.1.13",
43
43
  description: "Add setupTests.d.ts for @testing-library/jest-dom types and remove @types/testing-library__jest-dom npm package.",
44
44
  scriptPath: import.meta.resolve("./scripts/007-remove-testing-library-types.js")
45
+ },
46
+ {
47
+ name: "008-bundle-stats-permissions",
48
+ version: "7.3.2",
49
+ description: "Harden bundle-stats/bundle-size workflow permissions: contents permission was set to write but only read access is required; restricted to read for least-privilege.",
50
+ scriptPath: import.meta.resolve("./scripts/008-bundle-stats-permissions.js")
45
51
  }
46
52
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
47
53
  // for those written before the switch to updates as migrations.
@@ -0,0 +1,23 @@
1
+ import { parseDocument, stringify } from 'yaml';
2
+
3
+ const workflowPaths = ["./.github/workflows/bundle-stats.yml", "./.github/workflows/bundle-size.yml"];
4
+ async function migrate(context) {
5
+ for (const workflowPath of workflowPaths) {
6
+ if (!context.doesFileExist(workflowPath)) {
7
+ continue;
8
+ }
9
+ const workflowContent = context.getFile(workflowPath);
10
+ if (!workflowContent) {
11
+ continue;
12
+ }
13
+ const workflowDoc = parseDocument(workflowContent);
14
+ if (workflowDoc.getIn(["permissions", "contents"]) !== "write") {
15
+ continue;
16
+ }
17
+ workflowDoc.setIn(["permissions", "contents"], "read");
18
+ context.updateFile(workflowPath, stringify(workflowDoc));
19
+ }
20
+ return context;
21
+ }
22
+
23
+ export { migrate as default };
@@ -93,7 +93,7 @@ function getActionsForTemplateFolder({
93
93
  templateData
94
94
  }) {
95
95
  let files = glob.sync(`${folderPath}/**`, { dot: true });
96
- if (templateData.packageManagerName !== "npm") {
96
+ if (templateData.packageManagerName !== "pnpm") {
97
97
  files = files.filter((file) => path.basename(file) !== "npmrc");
98
98
  }
99
99
  files = files.filter((file) => {
@@ -17,17 +17,9 @@ async function configureYarn(cwd, packageManagerVersion) {
17
17
  shell: true,
18
18
  cwd
19
19
  });
20
- spawnSync("yarn", ["config", "set", "executeScripts", "false"], {
21
- shell: true,
22
- cwd
23
- });
24
- return "Configured Yarn Berry to use node_modules and disabled script execution during installation.";
20
+ return "Configured Yarn Berry to use node_modules (PnP is not supported)";
25
21
  }
26
- spawnSync("yarn", ["config", "set", "ignore-scripts", "true"], {
27
- shell: true,
28
- cwd
29
- });
30
- return "Configured Yarn to ignore scripts during installation.";
22
+ return "";
31
23
  } catch (error) {
32
24
  throw new Error(
33
25
  "There was an error configuring Yarn. Please run `yarn set version stable && yarn config set nodeLinker node-modules` in your plugin directory."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/create-plugin",
3
- "version": "7.4.0-canary.2630.25780630776.0",
3
+ "version": "7.4.0",
4
4
  "repository": {
5
5
  "directory": "packages/create-plugin",
6
6
  "url": "https://github.com/grafana/plugin-tools"
@@ -55,5 +55,5 @@
55
55
  "engines": {
56
56
  "node": ">=20"
57
57
  },
58
- "gitHead": "3dcab2a635d081430256b57dedf7a75a7b2cfbc8"
58
+ "gitHead": "d9190314df0893842f12e33bbdfb7d39c3a704cc"
59
59
  }
@@ -50,6 +50,13 @@ export default [
50
50
  'Add setupTests.d.ts for @testing-library/jest-dom types and remove @types/testing-library__jest-dom npm package.',
51
51
  scriptPath: import.meta.resolve('./scripts/007-remove-testing-library-types.js'),
52
52
  },
53
+ {
54
+ name: '008-bundle-stats-permissions',
55
+ version: '7.3.2',
56
+ description:
57
+ 'Harden bundle-stats/bundle-size workflow permissions: contents permission was set to write but only read access is required; restricted to read for least-privilege.',
58
+ scriptPath: import.meta.resolve('./scripts/008-bundle-stats-permissions.js'),
59
+ },
53
60
  // Do not use LEGACY_UPDATE_CUTOFF_VERSION for new migrations. It is only used above to force migrations to run
54
61
  // for those written before the switch to updates as migrations.
55
62
  ] satisfies Migration[];
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Context } from '../../context.js';
3
+ import migrate from './008-bundle-stats-permissions.js';
4
+ import { parse } from 'yaml';
5
+
6
+ const workflowPath = './.github/workflows/bundle-stats.yml';
7
+ const legacyWorkflowPath = './.github/workflows/bundle-size.yml';
8
+
9
+ describe('008-bundle-stats-permissions', () => {
10
+ it('should not modify anything if workflow file does not exist', async () => {
11
+ const context = new Context('/virtual');
12
+
13
+ await migrate(context);
14
+
15
+ expect(context.listChanges()).toEqual({});
16
+ });
17
+
18
+ it('should not modify anything if workflow file is empty', async () => {
19
+ const context = new Context('/virtual');
20
+ context.addFile(workflowPath, '');
21
+ const initialChanges = context.listChanges();
22
+
23
+ await migrate(context);
24
+
25
+ expect(context.listChanges()).toEqual(initialChanges);
26
+ });
27
+
28
+ it('should not modify anything if permissions block is missing', async () => {
29
+ const context = new Context('/virtual');
30
+ context.addFile(
31
+ workflowPath,
32
+ `name: Bundle Stats
33
+ on: [pull_request]
34
+ jobs:
35
+ compare:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v6
39
+ `
40
+ );
41
+ const initialChanges = context.listChanges();
42
+
43
+ await migrate(context);
44
+
45
+ expect(context.listChanges()).toEqual(initialChanges);
46
+ });
47
+
48
+ it('should not modify anything if permissions.contents key is missing', async () => {
49
+ const context = new Context('/virtual');
50
+ context.addFile(
51
+ workflowPath,
52
+ `name: Bundle Stats
53
+ on: [pull_request]
54
+ permissions:
55
+ pull-requests: write
56
+ actions: read
57
+ jobs:
58
+ compare:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v6
62
+ `
63
+ );
64
+ const initialChanges = context.listChanges();
65
+
66
+ await migrate(context);
67
+
68
+ expect(context.listChanges()).toEqual(initialChanges);
69
+ });
70
+
71
+ it('should not modify anything if permissions.contents is already read', async () => {
72
+ const context = new Context('/virtual');
73
+ const original = `name: Bundle Stats
74
+ on: [pull_request]
75
+ permissions:
76
+ contents: read
77
+ pull-requests: write
78
+ actions: read
79
+ jobs:
80
+ compare:
81
+ runs-on: ubuntu-latest
82
+ steps:
83
+ - uses: actions/checkout@v6
84
+ `;
85
+ context.addFile(workflowPath, original);
86
+
87
+ await migrate(context);
88
+
89
+ expect(context.getFile(workflowPath)).toBe(original);
90
+ });
91
+
92
+ it('should not modify anything if permissions.contents is none', async () => {
93
+ const context = new Context('/virtual');
94
+ const original = `name: Bundle Stats
95
+ on: [pull_request]
96
+ permissions:
97
+ contents: none
98
+ pull-requests: write
99
+ actions: read
100
+ jobs:
101
+ compare:
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ - uses: actions/checkout@v6
105
+ `;
106
+ context.addFile(workflowPath, original);
107
+
108
+ await migrate(context);
109
+
110
+ expect(context.getFile(workflowPath)).toBe(original);
111
+ });
112
+
113
+ it('should update permissions.contents from write to read', async () => {
114
+ const context = new Context('/virtual');
115
+ context.addFile(
116
+ workflowPath,
117
+ `name: Bundle Stats
118
+ on: [pull_request]
119
+ permissions:
120
+ contents: write
121
+ pull-requests: write
122
+ actions: read
123
+ jobs:
124
+ compare:
125
+ runs-on: ubuntu-latest
126
+ steps:
127
+ - uses: actions/checkout@v6
128
+ `
129
+ );
130
+
131
+ await migrate(context);
132
+
133
+ const migrated = parse(context.getFile(workflowPath) || '');
134
+ expect(migrated.permissions.contents).toBe('read');
135
+ });
136
+
137
+ it('should preserve other permissions when updating contents', async () => {
138
+ const context = new Context('/virtual');
139
+ context.addFile(
140
+ workflowPath,
141
+ `name: Bundle Stats
142
+ on: [pull_request]
143
+ permissions:
144
+ contents: write
145
+ pull-requests: write
146
+ actions: read
147
+ jobs:
148
+ compare:
149
+ runs-on: ubuntu-latest
150
+ steps:
151
+ - uses: actions/checkout@v6
152
+ `
153
+ );
154
+
155
+ await migrate(context);
156
+
157
+ const migrated = parse(context.getFile(workflowPath) || '');
158
+ expect(migrated.permissions).toEqual({
159
+ contents: 'read',
160
+ 'pull-requests': 'write',
161
+ actions: 'read',
162
+ });
163
+ });
164
+
165
+ it('should update permissions.contents in the legacy bundle-size.yml workflow', async () => {
166
+ const context = new Context('/virtual');
167
+ context.addFile(
168
+ legacyWorkflowPath,
169
+ `name: Bundle Stats
170
+ on: [pull_request]
171
+ permissions:
172
+ contents: write
173
+ pull-requests: write
174
+ actions: read
175
+ jobs:
176
+ compare:
177
+ runs-on: ubuntu-latest
178
+ steps:
179
+ - uses: actions/checkout@v6
180
+ `
181
+ );
182
+
183
+ await migrate(context);
184
+
185
+ const migrated = parse(context.getFile(legacyWorkflowPath) || '');
186
+ expect(migrated.permissions.contents).toBe('read');
187
+ });
188
+
189
+ it('should update both workflow filenames if both exist', async () => {
190
+ const context = new Context('/virtual');
191
+ const original = `name: Bundle Stats
192
+ on: [pull_request]
193
+ permissions:
194
+ contents: write
195
+ pull-requests: write
196
+ jobs:
197
+ compare:
198
+ runs-on: ubuntu-latest
199
+ steps:
200
+ - uses: actions/checkout@v6
201
+ `;
202
+ context.addFile(workflowPath, original);
203
+ context.addFile(legacyWorkflowPath, original);
204
+
205
+ await migrate(context);
206
+
207
+ expect(parse(context.getFile(workflowPath) || '').permissions.contents).toBe('read');
208
+ expect(parse(context.getFile(legacyWorkflowPath) || '').permissions.contents).toBe('read');
209
+ });
210
+
211
+ it('should be idempotent', async () => {
212
+ const context = new Context('/virtual');
213
+ context.addFile(
214
+ workflowPath,
215
+ `name: Bundle Stats
216
+ on: [pull_request]
217
+ permissions:
218
+ contents: write
219
+ pull-requests: write
220
+ actions: read
221
+ jobs:
222
+ compare:
223
+ runs-on: ubuntu-latest
224
+ steps:
225
+ - uses: actions/checkout@v6
226
+ `
227
+ );
228
+
229
+ await expect(migrate).toBeIdempotent(context);
230
+ });
231
+ });
@@ -0,0 +1,29 @@
1
+ import { type Context } from '../../context.js';
2
+ import { parseDocument, stringify } from 'yaml';
3
+
4
+ const workflowPaths = ['./.github/workflows/bundle-stats.yml', './.github/workflows/bundle-size.yml'];
5
+
6
+ export default async function migrate(context: Context) {
7
+ for (const workflowPath of workflowPaths) {
8
+ if (!context.doesFileExist(workflowPath)) {
9
+ continue;
10
+ }
11
+
12
+ const workflowContent = context.getFile(workflowPath);
13
+
14
+ if (!workflowContent) {
15
+ continue;
16
+ }
17
+
18
+ const workflowDoc = parseDocument(workflowContent);
19
+
20
+ if (workflowDoc.getIn(['permissions', 'contents']) !== 'write') {
21
+ continue;
22
+ }
23
+
24
+ workflowDoc.setIn(['permissions', 'contents'], 'read');
25
+ context.updateFile(workflowPath, stringify(workflowDoc));
26
+ }
27
+
28
+ return context;
29
+ }
@@ -135,7 +135,8 @@ function getActionsForTemplateFolder({
135
135
  }) {
136
136
  let files = glob.sync(`${folderPath}/**`, { dot: true });
137
137
 
138
- if (templateData.packageManagerName !== 'npm') {
138
+ // The npmrc file is only useful for `pnpm` settings. We can remove it for other package managers.
139
+ if (templateData.packageManagerName !== 'pnpm') {
139
140
  files = files.filter((file) => path.basename(file) !== 'npmrc');
140
141
  }
141
142
 
@@ -24,18 +24,9 @@ export async function configureYarn(cwd: string, packageManagerVersion: string)
24
24
  shell: true,
25
25
  cwd,
26
26
  });
27
- spawnSync('yarn', ['config', 'set', 'executeScripts', 'false'], {
28
- shell: true,
29
- cwd,
30
- });
31
- return 'Configured Yarn Berry to use node_modules and disabled script execution during installation.';
27
+ return 'Configured Yarn Berry to use node_modules (PnP is not supported)';
32
28
  }
33
- spawnSync('yarn', ['config', 'set', 'ignore-scripts', 'true'], {
34
- shell: true,
35
- cwd,
36
- });
37
-
38
- return 'Configured Yarn to ignore scripts during installation.';
29
+ return '';
39
30
  } catch (error) {
40
31
  throw new Error(
41
32
  'There was an error configuring Yarn. Please run `yarn set version stable && yarn config set nodeLinker node-modules` in your plugin directory.'
@@ -1 +1,12 @@
1
- ignore-scripts=true
1
+ # This file is required for PNPM
2
+
3
+ # PNPM 8 changed the default resolution mode to "lowest-direct" which is not how we expect resolutions to work
4
+ resolution-mode="highest"
5
+
6
+ # Make sure the default patterns are still included (https://pnpm.io/npmrc#public-hoist-pattern)
7
+ public-hoist-pattern[]="*eslint*"
8
+ public-hoist-pattern[]="*prettier*"
9
+
10
+ # Hoist all types packages to the root for better TS support
11
+ public-hoist-pattern[]="@types/*"
12
+ public-hoist-pattern[]="*terser-webpack-plugin*"
@@ -10,7 +10,7 @@ on:
10
10
  - main
11
11
 
12
12
  permissions:
13
- contents: write
13
+ contents: read
14
14
  pull-requests: write
15
15
  actions: read
16
16