package-registry-manager 0.21.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/src/flows.mjs ADDED
@@ -0,0 +1,722 @@
1
+ import { DEFAULT_TRUST_NPM, NPM_TFA_URL } from "./prerequisites.mjs";
2
+ import {
3
+ npmName,
4
+ registryEndpoint,
5
+ registryStateUrl,
6
+ } from "./registry-state.mjs";
7
+ import { CRATES_TOKENS_URL, tokenSecretSteps } from "./tokens.mjs";
8
+
9
+ /** Step conditions that only hold while a package is not yet published. */
10
+ export const BOOTSTRAP_CONDITIONS = new Set([
11
+ "package-missing",
12
+ "worktree-created",
13
+ ]);
14
+
15
+ /** Step conditions evaluated in the cleanup phase, after every other step. */
16
+ export const CLEANUP_CONDITIONS = new Set([
17
+ "tool-signed-in",
18
+ "worktree-created",
19
+ ]);
20
+
21
+ const command = (program, args) => ({ program, args });
22
+
23
+ function step(id, title, kind, description, fields = {}) {
24
+ return { id, title, kind, description, ...fields };
25
+ }
26
+
27
+ function packageCwd(directory) {
28
+ return directory === "." ? "{worktree}" : `{worktree}/${directory}`;
29
+ }
30
+
31
+ function repoArgs(context) {
32
+ return context.slug ? ["--repo", context.slug] : [];
33
+ }
34
+
35
+ function worktreeSteps() {
36
+ return [
37
+ step(
38
+ "fetch-default-branch",
39
+ "Fetch the default branch",
40
+ "command",
41
+ "Fetch the remote default branch so the first release is built from a clean, pushed commit.",
42
+ {
43
+ command: command("git", ["fetch", "origin", "HEAD"]),
44
+ when: "package-missing",
45
+ cwd: ".",
46
+ },
47
+ ),
48
+ step(
49
+ "prepare-worktree",
50
+ "Check out a temporary worktree",
51
+ "command",
52
+ "Check out the fetched commit into a temporary git worktree, leaving the working copy untouched.",
53
+ {
54
+ command: command("git", [
55
+ "worktree",
56
+ "add",
57
+ "--detach",
58
+ "{worktree}",
59
+ "FETCH_HEAD",
60
+ ]),
61
+ when: "package-missing",
62
+ cwd: ".",
63
+ },
64
+ ),
65
+ ];
66
+ }
67
+
68
+ function removeWorktreeStep() {
69
+ return step(
70
+ "remove-worktree",
71
+ "Remove the temporary worktree",
72
+ "command",
73
+ "Delete the temporary worktree and packed artifact.",
74
+ {
75
+ command: command("git", ["worktree", "remove", "--force", "{worktree}"]),
76
+ when: "worktree-created",
77
+ cwd: ".",
78
+ },
79
+ );
80
+ }
81
+
82
+ function checkRegistryStep(registryName, url) {
83
+ return step(
84
+ "check-registry",
85
+ `Look up the package on ${registryName}`,
86
+ "check",
87
+ "A missing package needs a first publish (bootstrap); an existing one only needs trusted publishing attached.",
88
+ { url },
89
+ );
90
+ }
91
+
92
+ function verifyReleaseSteps(context, url) {
93
+ const repo = repoArgs(context);
94
+ return [
95
+ step(
96
+ "trigger-release",
97
+ "Trigger the release workflow",
98
+ "command",
99
+ "Start the release workflow; if it has no workflow_dispatch trigger, the latest run is watched instead.",
100
+ {
101
+ command: command("gh", ["workflow", "run", context.workflow, ...repo]),
102
+ when: "release-dispatch",
103
+ cwd: ".",
104
+ },
105
+ ),
106
+ step(
107
+ "find-release-run",
108
+ "Find the release run",
109
+ "check",
110
+ "Look up the newest run of the release workflow.",
111
+ {
112
+ command: command("gh", [
113
+ "run",
114
+ "list",
115
+ ...repo,
116
+ "--workflow",
117
+ context.workflow,
118
+ "--limit",
119
+ "1",
120
+ "--json",
121
+ "databaseId,status,url",
122
+ ]),
123
+ when: "release-dispatch",
124
+ cwd: ".",
125
+ },
126
+ ),
127
+ step(
128
+ "watch-release",
129
+ "Watch the release run",
130
+ "command",
131
+ "Follow the run until it finishes and fail if it fails.",
132
+ {
133
+ command: command("gh", [
134
+ "run",
135
+ "watch",
136
+ "{run_id}",
137
+ ...repo,
138
+ "--exit-status",
139
+ ]),
140
+ when: "verify-release",
141
+ cwd: ".",
142
+ },
143
+ ),
144
+ step(
145
+ "confirm-provenance",
146
+ "Confirm the release used trusted publishing",
147
+ "wait",
148
+ "Poll the registry until the latest version was published by the workflow through OIDC, with provenance.",
149
+ { url, when: "verify-release" },
150
+ ),
151
+ ];
152
+ }
153
+
154
+ function twoFactorSteps() {
155
+ const profile = command("npm", ["profile", "get", "--json"]);
156
+ return [
157
+ step(
158
+ "check-2fa",
159
+ "Check npm two-factor authentication",
160
+ "check",
161
+ "npm trust requires account-level two-factor authentication, so check it before anything is published.",
162
+ { command: profile },
163
+ ),
164
+ step(
165
+ "enable-2fa",
166
+ "Turn on npm two-factor authentication",
167
+ "browser",
168
+ "Open the npm account's 2FA settings in your browser and turn on two-factor authentication.",
169
+ { url: NPM_TFA_URL, when: "tfa-disabled" },
170
+ ),
171
+ step(
172
+ "verify-2fa",
173
+ "Verify npm two-factor authentication",
174
+ "check",
175
+ "Stop before publishing if two-factor authentication is still off.",
176
+ { command: profile, when: "tfa-disabled" },
177
+ ),
178
+ ];
179
+ }
180
+
181
+ function releaseRunSteps(context) {
182
+ const repo = repoArgs(context);
183
+ return [
184
+ step(
185
+ "inspect-release-run",
186
+ "Read the latest release run",
187
+ "check",
188
+ "Look up the newest run of the release workflow; a run that npm rejected can be re-run once trust is attached.",
189
+ {
190
+ command: command("gh", [
191
+ "run",
192
+ "list",
193
+ ...repo,
194
+ "--workflow",
195
+ context.workflow,
196
+ "--limit",
197
+ "1",
198
+ "--json",
199
+ "databaseId,conclusion,status,url",
200
+ ]),
201
+ when: "trust-missing",
202
+ cwd: ".",
203
+ },
204
+ ),
205
+ step(
206
+ "read-release-failure",
207
+ "Read why the release run failed",
208
+ "check",
209
+ "Search the failed jobs' log for npm's E404 or invalid-publisher rejection.",
210
+ {
211
+ command: command("gh", [
212
+ "run",
213
+ "view",
214
+ "{failed_run_id}",
215
+ ...repo,
216
+ "--log-failed",
217
+ ]),
218
+ when: "release-failed",
219
+ cwd: ".",
220
+ },
221
+ ),
222
+ ];
223
+ }
224
+
225
+ export function npmFlow(packageInfo, context) {
226
+ const name = packageInfo.name;
227
+ const registry = registryEndpoint("npm");
228
+ const trustNpm = context.trustNpm ?? DEFAULT_TRUST_NPM;
229
+ const trustList = command("npx", [
230
+ "-y",
231
+ trustNpm,
232
+ "trust",
233
+ "list",
234
+ name,
235
+ "--json",
236
+ ]);
237
+ const steps = [
238
+ step(
239
+ "validate-package",
240
+ "Validate npm metadata",
241
+ "check",
242
+ "Read the package metadata with npm before changing registry settings.",
243
+ {
244
+ command: command("npm", [
245
+ "pkg",
246
+ "get",
247
+ "name",
248
+ "version",
249
+ "repository",
250
+ ]),
251
+ },
252
+ ),
253
+ checkRegistryStep("npm", registryStateUrl(packageInfo)),
254
+ step(
255
+ "check-sign-in",
256
+ "Check the npm session",
257
+ "check",
258
+ "Ask npm which account is signed in.",
259
+ { command: command("npm", ["whoami"]) },
260
+ ),
261
+ ...worktreeSteps(),
262
+ step(
263
+ "pack",
264
+ "Pack the package",
265
+ "command",
266
+ "Pack the clean checkout without lifecycle scripts and print the file list and size for review.",
267
+ {
268
+ command: command("npm", [
269
+ "pack",
270
+ "--ignore-scripts",
271
+ "--json",
272
+ "--pack-destination",
273
+ "{pack_destination}",
274
+ ]),
275
+ when: "package-missing",
276
+ cwd: packageCwd(context.directory),
277
+ },
278
+ ),
279
+ step(
280
+ "test-install",
281
+ "Install the packed tarball",
282
+ "command",
283
+ "Install the tarball into a scratch directory, without lifecycle scripts, to test it the way users get it.",
284
+ {
285
+ command: command("npm", [
286
+ "install",
287
+ "--no-save",
288
+ "--no-package-lock",
289
+ "--no-audit",
290
+ "--no-fund",
291
+ "--ignore-scripts",
292
+ "--prefix",
293
+ "{pack_destination}/install",
294
+ "{tarball}",
295
+ ]),
296
+ when: "package-missing",
297
+ cwd: "{pack_destination}",
298
+ },
299
+ ),
300
+ step(
301
+ "verify-bins",
302
+ "Verify the packed bin entries",
303
+ "check",
304
+ "Compare the bin entries of the packed package.json with package.json and check that each installed bin prints its version for --version.",
305
+ { when: "package-missing" },
306
+ ),
307
+ // Sign in right before publishing, so the sign-in, publish, and trust
308
+ // approvals happen together and npm can skip repeated 2FA prompts.
309
+ step(
310
+ "sign-in",
311
+ "Sign in to npm in the browser",
312
+ "command",
313
+ "Start a web login; the tool opens the printed URL in your default browser, where you are usually already signed in and only approve. No token is created or read by the tool.",
314
+ {
315
+ command: command("npm", [
316
+ "login",
317
+ "--auth-type=web",
318
+ "--browser=false",
319
+ ]),
320
+ when: "signed-out",
321
+ },
322
+ ),
323
+ ...twoFactorSteps(),
324
+ step(
325
+ "first-publish",
326
+ "Publish the first version",
327
+ "command",
328
+ "Publish the reviewed tarball once, approving 2FA in the browser. Later versions publish from CI through trusted publishing.",
329
+ {
330
+ command: command("npm", [
331
+ "publish",
332
+ "{tarball}",
333
+ "--access",
334
+ "public",
335
+ "--auth-type=web",
336
+ "--browser=false",
337
+ "--provenance=false",
338
+ ]),
339
+ when: "package-missing",
340
+ cwd: packageCwd(context.directory),
341
+ confirm: true,
342
+ },
343
+ ),
344
+ step(
345
+ "wait-for-registry",
346
+ "Wait for the registry",
347
+ "wait",
348
+ "Poll the registry until the published version is visible.",
349
+ {
350
+ url: `${registry}/${npmName(name)}/{version}`,
351
+ when: "package-missing",
352
+ },
353
+ ),
354
+ ];
355
+ if (context.slug && context.workflow) {
356
+ steps.push(
357
+ step(
358
+ "check-trust",
359
+ "Check trusted publishing",
360
+ "check",
361
+ "List the trusted publishers configured for the package.",
362
+ { command: trustList },
363
+ ),
364
+ ...releaseRunSteps(context),
365
+ step(
366
+ "attach-trusted-publisher",
367
+ "Attach the GitHub Actions trusted publisher",
368
+ "command",
369
+ `Trust the release workflow to publish through OIDC with ${trustNpm}, run through npx; npm trust needs npm 11.10 or newer and account-level 2FA.`,
370
+ {
371
+ command: command("npx", [
372
+ "-y",
373
+ trustNpm,
374
+ "trust",
375
+ "github",
376
+ name,
377
+ "--repo",
378
+ context.slug,
379
+ "--file",
380
+ context.workflow,
381
+ ...(context.environment ? ["--env", context.environment] : []),
382
+ "--allow-publish",
383
+ "--yes",
384
+ "--browser=false",
385
+ ]),
386
+ when: "trust-missing",
387
+ },
388
+ ),
389
+ );
390
+ }
391
+ steps.push(
392
+ step(
393
+ "configure-trusted-publisher",
394
+ "Configure npm trusted publishing in the browser",
395
+ "browser",
396
+ "Fallback when the npm CLI cannot attach the publisher: review the prefilled GitHub Actions identity and explicitly confirm submission.",
397
+ {
398
+ url: `https://www.npmjs.com/package/${urlPathSegment(name)}/access`,
399
+ when:
400
+ context.slug && context.workflow
401
+ ? "trust-cli-failed"
402
+ : "trust-missing",
403
+ },
404
+ ),
405
+ );
406
+ if (context.slug && context.workflow) {
407
+ steps.push(
408
+ step(
409
+ "verify-trusted-publisher",
410
+ "Verify trusted publishing",
411
+ "check",
412
+ "Confirm that npm lists the GitHub Actions trusted publisher.",
413
+ { command: trustList, when: "trust-missing" },
414
+ ),
415
+ ...tokenSecretSteps(packageInfo, context.slug),
416
+ step(
417
+ "rerun-release",
418
+ "Re-run the failed release jobs",
419
+ "command",
420
+ "Re-run the jobs of the release run that npm rejected, now that the trusted publisher is attached.",
421
+ {
422
+ command: command("gh", [
423
+ "run",
424
+ "rerun",
425
+ "{failed_run_id}",
426
+ ...repoArgs(context),
427
+ "--failed",
428
+ ]),
429
+ when: "release-failed-publish",
430
+ cwd: ".",
431
+ confirm: true,
432
+ },
433
+ ),
434
+ );
435
+ if (context.verifyRelease) {
436
+ steps.push(...verifyReleaseSteps(context, registryStateUrl(packageInfo)));
437
+ }
438
+ }
439
+ steps.push(
440
+ step(
441
+ "sign-out",
442
+ "Sign out of npm",
443
+ "command",
444
+ "Revoke the session token created by the web login.",
445
+ { command: command("npm", ["logout"]), when: "tool-signed-in" },
446
+ ),
447
+ removeWorktreeStep(),
448
+ );
449
+ return steps;
450
+ }
451
+
452
+ export function cratesFlow(packageInfo, context) {
453
+ const name = packageInfo.name;
454
+ const api = registryStateUrl(packageInfo);
455
+ const steps = [
456
+ step(
457
+ "validate-package",
458
+ "Validate the crate",
459
+ "check",
460
+ "Package the crate without uploading it.",
461
+ { command: command("cargo", ["publish", "--dry-run"]) },
462
+ ),
463
+ checkRegistryStep("crates.io", api),
464
+ ...worktreeSteps(),
465
+ step(
466
+ "create-publish-token",
467
+ "Create a one-time first-publish token",
468
+ "browser",
469
+ `A one-time exception that needs your approval: crates.io has no token-free first publish (no pending publisher as on PyPI), so the first upload needs an API token. Create one limited to the publish-new scope and the crate name ${name}, expiring in a day; paste it only into cargo login in the next step. It is revoked, and the revocation verified, before setup ends.`,
470
+ {
471
+ url: "https://crates.io/settings/tokens/new",
472
+ when: "package-missing",
473
+ confirm: true,
474
+ },
475
+ ),
476
+ step(
477
+ "sign-in",
478
+ "Sign cargo in",
479
+ "command",
480
+ "cargo reads the token from the terminal, so it is never typed into the tool.",
481
+ { command: command("cargo", ["login"]), when: "package-missing" },
482
+ ),
483
+ step(
484
+ "first-publish",
485
+ "Publish the first version",
486
+ "command",
487
+ "Publish once from the clean checkout. Later versions publish from CI through trusted publishing.",
488
+ {
489
+ command: command("cargo", ["publish"]),
490
+ when: "package-missing",
491
+ cwd: packageCwd(context.directory),
492
+ confirm: true,
493
+ },
494
+ ),
495
+ step(
496
+ "wait-for-registry",
497
+ "Wait for the registry",
498
+ "wait",
499
+ "Poll crates.io until the crate is visible.",
500
+ { url: api, when: "package-missing" },
501
+ ),
502
+ step(
503
+ "revoke-publish-token",
504
+ "Revoke the first-publish token",
505
+ "browser",
506
+ "Revoke the token created for the first publish; it is no longer needed.",
507
+ { url: CRATES_TOKENS_URL, when: "package-missing" },
508
+ ),
509
+ step(
510
+ "verify-token-revoked",
511
+ "Verify the first-publish token is revoked",
512
+ "check",
513
+ "Send the token cargo stored to crates.io, which must reject it; the token is never printed.",
514
+ {
515
+ url: `${registryEndpoint("crates-io")}/me/tokens`,
516
+ when: "package-missing",
517
+ },
518
+ ),
519
+ step(
520
+ "configure-trusted-publisher",
521
+ "Configure crates.io trusted publishing",
522
+ "browser",
523
+ "Add the repository's GitHub Actions workflow as the crate's trusted publisher, then review and submit.",
524
+ {
525
+ url: `https://crates.io/crates/${encodeURIComponent(name)}/settings/new-trusted-publisher`,
526
+ when: "trust-missing",
527
+ },
528
+ ),
529
+ ];
530
+ if (context.slug) {
531
+ steps.push(...tokenSecretSteps(packageInfo, context.slug));
532
+ }
533
+ steps.push(
534
+ step(
535
+ "sign-out",
536
+ "Sign cargo out",
537
+ "command",
538
+ "Remove the local copy of the first-publish token.",
539
+ { command: command("cargo", ["logout"]), when: "tool-signed-in" },
540
+ ),
541
+ removeWorktreeStep(),
542
+ );
543
+ return steps;
544
+ }
545
+
546
+ export function pypiFlow(packageInfo, context) {
547
+ const name = packageInfo.name;
548
+ const steps = [
549
+ step(
550
+ "build-package",
551
+ "Build the Python distribution",
552
+ "check",
553
+ "Build source and wheel distributions locally.",
554
+ { command: command("python", ["-m", "build"]) },
555
+ ),
556
+ checkRegistryStep("PyPI", registryStateUrl(packageInfo)),
557
+ step(
558
+ "create-pending-publisher",
559
+ "Create a PyPI pending publisher",
560
+ "browser",
561
+ "Register the GitHub Actions workflow as a pending publisher, so the first upload comes from CI through OIDC. A pending publisher does not reserve the name.",
562
+ {
563
+ url: "https://pypi.org/manage/account/publishing/",
564
+ when: "package-missing",
565
+ },
566
+ ),
567
+ ];
568
+ if (context.workflow) {
569
+ steps.push(
570
+ step(
571
+ "trigger-release",
572
+ "Run the release workflow",
573
+ "command",
574
+ "Start the release workflow, which performs the first upload through OIDC with no manual upload.",
575
+ {
576
+ command: command("gh", [
577
+ "workflow",
578
+ "run",
579
+ context.workflow,
580
+ ...repoArgs(context),
581
+ ]),
582
+ when: "package-missing",
583
+ cwd: ".",
584
+ },
585
+ ),
586
+ );
587
+ }
588
+ steps.push(
589
+ step(
590
+ "wait-for-registry",
591
+ "Wait for the registry",
592
+ "wait",
593
+ "Poll PyPI until the project is visible.",
594
+ { url: registryStateUrl(packageInfo), when: "package-missing" },
595
+ ),
596
+ step(
597
+ "configure-trusted-publisher",
598
+ "Configure a PyPI trusted publisher",
599
+ "browser",
600
+ "Sign in and add the repository's GitHub Actions workflow as a trusted publisher.",
601
+ {
602
+ url: `https://pypi.org/manage/project/${urlPathSegment(name)}/settings/publishing/`,
603
+ when: "trust-missing",
604
+ },
605
+ ),
606
+ );
607
+ if (context.slug) {
608
+ steps.push(...tokenSecretSteps(packageInfo, context.slug));
609
+ }
610
+ return steps;
611
+ }
612
+
613
+ export function dockerHubFlow(packageInfo, context) {
614
+ const [namespace] = packageInfo.name.split("/");
615
+ const repo = repoArgs(context);
616
+ return [
617
+ checkRegistryStep("Docker Hub", registryStateUrl(packageInfo)),
618
+ step(
619
+ "create-repository",
620
+ "Create the Docker Hub repository",
621
+ "browser",
622
+ `Create the public repository ${packageInfo.name}.`,
623
+ {
624
+ url: `https://hub.docker.com/repository/create?namespace=${encodeURIComponent(namespace)}`,
625
+ when: "package-missing",
626
+ },
627
+ ),
628
+ step(
629
+ "create-access-token",
630
+ "Create a Read & Write access token",
631
+ "browser",
632
+ `Create a token with Read & Write access and a short expiry. Personal access tokens cannot be limited to one repository; an organization access token can be restricted to ${packageInfo.name}. Keep the token only for the next step.`,
633
+ { url: "https://app.docker.com/settings/personal-access-tokens/create" },
634
+ ),
635
+ step(
636
+ "check-github-cli",
637
+ "Check the GitHub CLI session",
638
+ "check",
639
+ "gh stores the variables and secret in the repository.",
640
+ { command: command("gh", ["auth", "status"]), cwd: "." },
641
+ ),
642
+ step(
643
+ "set-image-variable",
644
+ "Set DOCKERHUB_IMAGE",
645
+ "command",
646
+ "Store the image name as a repository variable.",
647
+ {
648
+ command: command("gh", [
649
+ "variable",
650
+ "set",
651
+ "DOCKERHUB_IMAGE",
652
+ "--body",
653
+ packageInfo.name,
654
+ ...repo,
655
+ ]),
656
+ cwd: ".",
657
+ },
658
+ ),
659
+ step(
660
+ "set-username-variable",
661
+ "Set DOCKERHUB_USERNAME",
662
+ "command",
663
+ "Store the Docker Hub account as a repository variable.",
664
+ {
665
+ command: command("gh", [
666
+ "variable",
667
+ "set",
668
+ "DOCKERHUB_USERNAME",
669
+ "--body",
670
+ namespace,
671
+ ...repo,
672
+ ]),
673
+ cwd: ".",
674
+ },
675
+ ),
676
+ step(
677
+ "set-token-secret",
678
+ "Set DOCKERHUB_TOKEN",
679
+ "command",
680
+ "gh reads the token from the terminal without echoing it; the tool never sees it.",
681
+ {
682
+ command: command("gh", ["secret", "set", "DOCKERHUB_TOKEN", ...repo]),
683
+ cwd: ".",
684
+ confirm: true,
685
+ },
686
+ ),
687
+ ];
688
+ }
689
+
690
+ export function ghcrFlow(packageInfo, context) {
691
+ const [owner, ...rest] = packageInfo.name.split("/");
692
+ const steps = [];
693
+ if ((packageInfo.warnings ?? []).length > 0) {
694
+ steps.push(
695
+ step(
696
+ "grant-packages-write",
697
+ "Grant packages: write",
698
+ "manual",
699
+ `Add "permissions: packages: write" to the job in ${context.workflow} that pushes to ghcr.io.`,
700
+ {
701
+ url: "https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions",
702
+ },
703
+ ),
704
+ );
705
+ }
706
+ steps.push(
707
+ step(
708
+ "link-package",
709
+ "Link the package to the repository",
710
+ "browser",
711
+ `After the first push, open Package settings, connect ${context.slug ?? "the repository"} and grant it Actions access. A LABEL org.opencontainers.image.source=https://github.com/${context.slug ?? "<owner>/<repository>"} in the Dockerfile links it automatically.`,
712
+ {
713
+ url: `https://github.com/users/${encodeURIComponent(owner)}/packages/container/package/${rest.map(encodeURIComponent).join("%2F")}`,
714
+ },
715
+ ),
716
+ );
717
+ return steps;
718
+ }
719
+
720
+ export function urlPathSegment(value) {
721
+ return encodeURIComponent(value).replaceAll("%40", "@");
722
+ }