Skip to content

Commit dcea2bd

Browse files
authored
feat: add instructions for NPM Trusted Publishing (#1685)
[CS-275](https://linear.app/speakeasy/issue/CS-275/feature-support-trusted-publishers-npm) [RFC](https://www.notion.so/speakeasyapi/RFC-NPM-Trusted-Publishing-29c726c497cc805f9674c0918e52e7c5?v=99bac25b265e455c871a0c3abc64f191&source=copy_link) - [X] relies on speakeasy-api/sdk-generation-action#266 being merged/released first ### Sample output for a single target (`pr` mode -> `sdk_publish.yaml`) <img width="1655" height="527" alt="screenshot_2025-11-03_12:26:56" src="https://github.com/user-attachments/assets/4518715f-03ab-4c15-821c-ef8f43ab64d1" /> ### Sample output for a single target (`direct` mode -> `sdk_generation.yaml`) <img width="1660" height="524" alt="screenshot_2025-11-03_12:25:35" src="https://github.com/user-attachments/assets/d3aafe39-b436-451a-9573-6b9a6b210001" /> ### Sample output for 2 targets in the same repo (`typescript` + `mcp-typescript`) <img width="1916" height="807" alt="screenshot_2025-10-31_15:29:14" src="https://github.com/user-attachments/assets/bc4ccbde-90c0-486b-b0b4-3160340a8104" />
1 parent 81b8133 commit dcea2bd

File tree

2 files changed

+154
-41
lines changed

2 files changed

+154
-41
lines changed

cmd/configure.go

Lines changed: 145 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"os"
88
"path/filepath"
9+
"regexp"
910
"slices"
1011
"strings"
1112

@@ -493,21 +494,18 @@ func configurePublishing(ctx context.Context, flags ConfigureGithubFlags) error
493494
}
494495

495496
secrets := make(map[string]string)
496-
var publishPaths, generationWorkflowFilePaths []string
497+
workflowPaths := make(map[string]targetWorkflowPaths)
497498

498499
for _, name := range chosenTargets {
499-
generationWorkflow, generationWorkflowFilePath, newPaths, err := writePublishingFile(workflowFile, workflowFile.Targets[name], name, rootDir, actionWorkingDir)
500+
generationWorkflow, targetWorkflowPaths, err := writePublishingFile(workflowFile, workflowFile.Targets[name], name, rootDir, actionWorkingDir)
500501
if err != nil {
501502
return err
502503
}
503504
for key, val := range generationWorkflow.Jobs.Generate.Secrets {
504505
secrets[key] = val
505506
}
506507

507-
if len(newPaths) > 0 {
508-
publishPaths = append(publishPaths, newPaths...)
509-
}
510-
generationWorkflowFilePaths = append(generationWorkflowFilePaths, generationWorkflowFilePath)
508+
workflowPaths[name] = targetWorkflowPaths
511509
}
512510

513511
if err := workflow.Save(filepath.Join(rootDir, actionWorkingDir), workflowFile); err != nil {
@@ -532,6 +530,12 @@ func configurePublishing(ctx context.Context, flags ConfigureGithubFlags) error
532530
status := []string{
533531
fmt.Sprintf("Speakeasy workflow written to - %s", workflowFilePath),
534532
}
533+
534+
var publishPaths, generationWorkflowFilePaths []string
535+
for _, wfp := range workflowPaths {
536+
publishPaths = append(publishPaths, wfp.publishWorkflowPaths...)
537+
generationWorkflowFilePaths = append(generationWorkflowFilePaths, wfp.generationWorkflowPath)
538+
}
535539
if len(publishPaths) > 0 {
536540
status = append(status, "GitHub action (generate) written to:")
537541
for _, path := range generationWorkflowFilePaths {
@@ -548,31 +552,138 @@ func configurePublishing(ctx context.Context, flags ConfigureGithubFlags) error
548552
}
549553
}
550554

551-
var agenda []string
555+
agenda := []string{
556+
fmt.Sprintf("• On GitHub navigate to %s and set up the following repository secrets:", secretPath),
557+
}
558+
552559
for key := range secrets {
553560
if key != config.GithubAccessToken {
554561
agenda = append(agenda, fmt.Sprintf("\t◦ Provide a secret with name %s", styles.MakeBold(strings.ToUpper(key))))
555562
}
556563
}
557-
agenda = append(agenda, fmt.Sprintf("• Push your repository to github! Navigate to %s to kick of your first publish.", actionPath))
564+
565+
agenda = append(agenda, fmt.Sprintf("• Push your repository to GitHub and navigate to %s to kick off your first publish!", actionPath))
566+
567+
// Add instructions for NPM Trusted Publishing (typescript/mcp-typescript)
568+
npmTrustedPublishingConfigs := make(map[string]NPMTrustedPublishingConfig)
569+
for name, wfp := range workflowPaths {
570+
target := workflowFile.Targets[name]
571+
if target.Publishing.NPM != nil {
572+
var publishPath string
573+
switch len(wfp.publishWorkflowPaths) {
574+
case 0:
575+
// No publish path means generation and publishing are combined into a single workflow
576+
publishPath = wfp.generationWorkflowPath
577+
case 1:
578+
publishPath = wfp.publishWorkflowPaths[0]
579+
default:
580+
// For typescript/mcp-typescript, if the publish and generation workflow files
581+
// are distinct (pr mode), then we only expect a single publish path.
582+
return errors.Wrapf(err, "multiple publish workflow paths found for target %s", name)
583+
}
584+
585+
// Get the packageName from the config file
586+
packageName := "<packageName>"
587+
outDir := ""
588+
if target.Output != nil {
589+
outDir = *target.Output
590+
}
591+
workflowDir := filepath.Join(rootDir, actionWorkingDir)
592+
configPath := filepath.Join(workflowDir, outDir)
593+
cfg, err := config.Load(configPath)
594+
if err == nil {
595+
if langCfg, ok := cfg.Config.Languages[target.Target]; ok {
596+
if pkgName, ok := langCfg.Cfg["packageName"].(string); ok {
597+
packageName = pkgName
598+
}
599+
}
600+
}
601+
602+
npmTrustedPublishingConfigs[name] = NPMTrustedPublishingConfig{
603+
target: target,
604+
workflowDir: filepath.Join(rootDir, actionWorkingDir),
605+
actionPath: actionPath,
606+
publishFileName: filepath.Base(publishPath),
607+
packageName: packageName,
608+
remoteURL: remoteURL,
609+
}
610+
}
611+
}
612+
agenda = append(agenda, getNPMTrustedPublishingInstructions(ctx, npmTrustedPublishingConfigs)...)
558613

559614
logger.Println(styles.Info.Render("Files successfully generated!\n"))
560615
for _, statusMsg := range status {
561616
logger.Println(styles.Info.Render(fmt.Sprintf("• %s", statusMsg)))
562617
}
563618
logger.Println(styles.Info.Render("\n"))
564619

565-
if len(agenda) != 0 {
566-
agenda = append([]string{
567-
fmt.Sprintf("• In your repo navigate to %s and setup the following repository secrets:", secretPath),
568-
}, agenda...)
620+
msg := styles.RenderInstructionalMessage("For your publishing setup to be complete perform the following steps.",
621+
agenda...)
622+
logger.Println(msg)
623+
624+
return nil
625+
}
626+
627+
type NPMTrustedPublishingConfig = struct {
628+
target workflow.Target
629+
workflowDir string
630+
actionPath string
631+
publishFileName string
632+
packageName string
633+
remoteURL string
634+
}
635+
636+
func getNPMTrustedPublishingInstructions(ctx context.Context, npmConfigs map[string]NPMTrustedPublishingConfig) []string {
637+
var agenda []string
638+
639+
// Collect unique action paths
640+
actionPaths := make(map[string][]string)
641+
for _, npmConfig := range npmConfigs {
642+
if _, exists := actionPaths[npmConfig.actionPath]; !exists {
643+
actionPaths[npmConfig.actionPath] = []string{}
644+
}
645+
actionPaths[npmConfig.actionPath] = append(actionPaths[npmConfig.actionPath], npmConfig.packageName)
646+
}
647+
648+
for targetName, npmConfig := range npmConfigs {
649+
repoOwner := "<user>"
650+
repoName := "<repository>"
651+
if npmConfig.remoteURL != "" {
652+
// Expected format: "https://github.com/<user>/<repository>"
653+
re := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/?$`)
654+
matches := re.FindStringSubmatch(npmConfig.remoteURL)
655+
if len(matches) == 3 {
656+
repoOwner = matches[1]
657+
repoName = matches[2]
658+
}
659+
}
569660

570-
msg := styles.RenderInstructionalMessage("For your publishing setup to be complete perform the following steps.",
571-
agenda...)
572-
logger.Println(msg)
661+
if len(npmConfigs) == 1 {
662+
agenda = append(agenda, fmt.Sprintf("• Access your newly published package's settings at https://www.npmjs.com/package/%s/access", npmConfig.packageName))
663+
} else {
664+
agenda = append(agenda, fmt.Sprintf("• [%s] Access the '%s' package's settings at https://www.npmjs.com/package/%s/access", strings.ToUpper(npmConfig.target.Target), targetName, npmConfig.packageName))
665+
}
666+
667+
configLines := []string{
668+
fmt.Sprintf("\t\t- Organization or user: %s", repoOwner),
669+
fmt.Sprintf("\t\t- Repository: %s", repoName),
670+
fmt.Sprintf("\t\t- Workflow filename: %s", npmConfig.publishFileName),
671+
"\t\t- Environment name: <Leave Blank>",
672+
}
673+
agenda = append(agenda, fmt.Sprintf("\t◦ Add 'GitHub Actions' as a 'Trusted Publisher' with the following configuration:\n%s", strings.Join(configLines, "\n")))
573674
}
574675

575-
return nil
676+
for actionPath, packageNames := range actionPaths {
677+
if len(packageNames) == 1 {
678+
agenda = append(agenda, fmt.Sprintf("• Navigate to %s to regenerate and publish a new version of the %s package.", actionPath, packageNames[0]))
679+
agenda = append(agenda, fmt.Sprintf("• Your package's latest version should now include a 'Provenance' at https://www.npmjs.com/package/%s#provenance", packageNames[0]))
680+
} else {
681+
agenda = append(agenda, fmt.Sprintf("• Navigate to %s to regenerate and publish new versions of your packages.", actionPath))
682+
agenda = append(agenda, "• Your packages' latest versions should now be labelled with a green check mark and include a 'Provenance'.")
683+
}
684+
}
685+
686+
return agenda
576687
}
577688

578689
func configureTesting(ctx context.Context, flags ConfigureTestsFlags) error {
@@ -898,7 +1009,7 @@ func configureGithub(ctx context.Context, flags ConfigureGithubFlags) error {
8981009
}
8991010

9001011
if len(secrets) > 2 || !autoConfigureRepoSuccess {
901-
agenda = append(agenda, fmt.Sprintf("• In your repo navigate to %s and setup the following repository secrets:", secretPath))
1012+
agenda = append(agenda, fmt.Sprintf("• On GitHub navigate to %s and set up the following repository secrets:", secretPath))
9021013
}
9031014

9041015
for key := range secrets {
@@ -955,35 +1066,42 @@ func writeGenerationFile(workflowFile *workflow.Workflow, workingDir, workflowFi
9551066
return generationWorkflow, generationWorkflowFilePath, nil
9561067
}
9571068

958-
func writePublishingFile(wf *workflow.Workflow, target workflow.Target, targetName, currentWorkingDir, workflowFileDir string) (*config.GenerateWorkflow, string, []string, error) {
959-
generationWorkflowFilePath := filepath.Join(currentWorkingDir, ".github/workflows/sdk_generation.yaml")
1069+
type targetWorkflowPaths struct {
1070+
generationWorkflowPath string
1071+
publishWorkflowPaths []string
1072+
}
1073+
1074+
func writePublishingFile(wf *workflow.Workflow, target workflow.Target, targetName, currentWorkingDir, workflowFileDir string) (*config.GenerateWorkflow, targetWorkflowPaths, error) {
1075+
paths := targetWorkflowPaths{}
1076+
paths.generationWorkflowPath = filepath.Join(currentWorkingDir, ".github/workflows/sdk_generation.yaml")
9601077
if len(wf.Targets) > 1 {
9611078
sanitizedName := strings.ReplaceAll(strings.ToLower(targetName), "-", "_")
962-
generationWorkflowFilePath = filepath.Join(currentWorkingDir, fmt.Sprintf(".github/workflows/sdk_generation_%s.yaml", sanitizedName))
1079+
paths.generationWorkflowPath = filepath.Join(currentWorkingDir, fmt.Sprintf(".github/workflows/sdk_generation_%s.yaml", sanitizedName))
9631080
}
9641081

9651082
if _, err := os.Stat(filepath.Join(currentWorkingDir, ".github/workflows")); os.IsNotExist(err) {
9661083
err = os.MkdirAll(filepath.Join(currentWorkingDir, ".github/workflows"), 0o755)
9671084
if err != nil {
968-
return nil, "", nil, err
1085+
return nil, paths, err
9691086
}
9701087
}
9711088

9721089
generationWorkflow := &config.GenerateWorkflow{}
973-
if err := prompts.ReadGenerationFile(generationWorkflow, generationWorkflowFilePath); err != nil {
974-
return nil, "", nil, fmt.Errorf("you cannot run configure publishing when a github workflow file %s does not exist, try speakeasy configure github", generationWorkflowFilePath)
1090+
if err := prompts.ReadGenerationFile(generationWorkflow, paths.generationWorkflowPath); err != nil {
1091+
return nil, paths, fmt.Errorf("you cannot run configure publishing when a github workflow file %s does not exist, try speakeasy configure github", paths.generationWorkflowPath)
9751092
}
9761093

9771094
publishPaths, err := prompts.WritePublishing(wf, generationWorkflow, targetName, currentWorkingDir, workflowFileDir, target)
9781095
if err != nil {
979-
return nil, "", nil, errors.Wrapf(err, "failed to write publishing configs")
1096+
return nil, paths, errors.Wrapf(err, "failed to write publishing configs")
9801097
}
9811098

982-
if err = prompts.WriteGenerationFile(generationWorkflow, generationWorkflowFilePath); err != nil {
983-
return nil, "", nil, errors.Wrapf(err, "failed to write github workflow file")
1099+
paths.publishWorkflowPaths = publishPaths
1100+
if err = prompts.WriteGenerationFile(generationWorkflow, paths.generationWorkflowPath); err != nil {
1101+
return nil, paths, errors.Wrapf(err, "failed to write github workflow file")
9841102
}
9851103

986-
return generationWorkflow, generationWorkflowFilePath, publishPaths, nil
1104+
return generationWorkflow, paths, nil
9871105
}
9881106

9891107
func handleLegacySDKTarget(workingDir string, workflowFile *workflow.Workflow) ([]string, []huh.Option[string]) {

prompts/github.go

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -440,31 +440,25 @@ func WriteTestingFiles(ctx context.Context, wf *workflow.Workflow, currentWorkin
440440
}
441441

442442
// WritePublishing writes a github action file for a given target for publishing to a package manager.
443-
// If filenameAddendum is provided, it will be appended to the filename (i.e. sdk_publish_lending.yaml).
443+
// If multiple targets are defined in the workflow, the filename will be suffixed with the target name.
444444
// Returns the paths to the files written.
445445
func WritePublishing(wf *workflow.Workflow, genWorkflow *config.GenerateWorkflow, targetName, currentWorkingDir, workflowFileDir string, target workflow.Target) ([]string, error) {
446-
secrets := make(map[string]string)
447-
secrets[config.GithubAccessToken] = formatGithubSecretName(defaultGithubTokenSecretName)
448-
secrets[config.SpeakeasyApiKey] = formatGithubSecretName(defaultSpeakeasyAPIKeySecretName)
446+
genSecrets := genWorkflow.Jobs.Generate.Secrets
447+
genSecrets[config.GithubAccessToken] = formatGithubSecretName(defaultGithubTokenSecretName)
448+
genSecrets[config.SpeakeasyApiKey] = formatGithubSecretName(defaultSpeakeasyAPIKeySecretName)
449449

450450
var terraformOutDir *string
451451

452452
if target.Publishing != nil {
453453
for _, secret := range getSecretsValuesFromPublishing(*target.Publishing) {
454-
secrets[formatGithubSecret(secret)] = formatGithubSecretName(secret)
454+
genSecrets[formatGithubSecret(secret)] = formatGithubSecretName(secret)
455455
}
456456

457457
if target.Target == "terraform" {
458458
terraformOutDir = target.Output
459459
}
460460
}
461461

462-
currentSecrets := genWorkflow.Jobs.Generate.Secrets
463-
for secret, value := range secrets {
464-
currentSecrets[secret] = value
465-
}
466-
genWorkflow.Jobs.Generate.Secrets = currentSecrets
467-
468462
mode := genWorkflow.Jobs.Generate.With[config.Mode].(string)
469463
if target.Target == "terraform" {
470464
releaseActionPath := filepath.Join(currentWorkingDir, ".github/workflows/tf_provider_release.yaml")
@@ -495,7 +489,7 @@ func WritePublishing(wf *workflow.Workflow, genWorkflow *config.GenerateWorkflow
495489
publishingFile = defaultPublishingFile()
496490
}
497491

498-
// backfill id-token write permissions
492+
// backfill `id-token: write` permission (OIDC)
499493
if publishingFile.Permissions.IDToken != config.GithubWritePermission {
500494
publishingFile.Permissions.IDToken = config.GithubWritePermission
501495
}
@@ -521,8 +515,9 @@ func WritePublishing(wf *workflow.Workflow, genWorkflow *config.GenerateWorkflow
521515
publishingFile.Jobs.Publish.With["working_directory"] = workflowFileDir
522516
}
523517

524-
for name, value := range secrets {
525-
publishingFile.Jobs.Publish.Secrets[name] = value
518+
pubSecrets := publishingFile.Jobs.Publish.Secrets
519+
for name, value := range genSecrets {
520+
pubSecrets[name] = value
526521
}
527522

528523
// Write a github publishing file.

0 commit comments

Comments
 (0)