Skip to content

Add VCS module publish command - #105

Open
AadarshIBM wants to merge 1 commit into
hashicorp:mainfrom
AadarshIBM:module-publish
Open

Add VCS module publish command#105
AadarshIBM wants to merge 1 commit into
hashicorp:mainfrom
AadarshIBM:module-publish

Conversation

@AadarshIBM

@AadarshIBM AadarshIBM commented Aug 24, 2026

Copy link
Copy Markdown

Description

Adds tfctl module publish for publishing VCS private registry modules from existing OAuth or GitHub App connections.

The command:

  • Requires a repository and exactly one VCS connection ID.
  • Uses tag-based publishing by default.
  • Supports branch-based publishing with an optional initial version.
  • Resolves the organization from the explicit flag, active profile, or Terraform configuration.
  • Supports JSON, Markdown, dry-run, and quiet behavior.
  • Uses tfctl's existing authenticated client because go-tfe/v2 does not yet provide a method for this endpoint.
  • Returns immediately without polling and reports the API status verbatim.
  • Restricts output and logs to safe module metadata.

For repositories that follow the standard terraform-<provider>-<name> naming convention, HCP Terraform automatically determines the module name and provider. Custom name/provider overrides and repositories requiring different VCS identifier and display-identifier values are outside this initial scope and can use tfctl api.

Validation completed:

  • go test ./internal/commands/module ./internal/commands/root -count=1
  • make gen/screenshot
  • make bin
  • make check
  • go test ./... -race
  • git diff --check

Example Output

Tag-based publishing dry run:

$ tfctl module publish \
    --repo acme/terraform-aws-network \
    --oauth-token-id ot-... \
    --organization acme \
    --dry-run

DRY RUN: would publish VCS-backed module from repository "acme/terraform-aws-network" to organization "acme" using tag-based publishing

Branch-based publishing:

$ tfctl module publish \
    --repo acme/terraform-aws-network \
    --github-app-installation-id ghain-... \
    --branch main \
    --initial-version 1.0.0

PR Checklist

  • Prepared a changelog entry for the next set of release notes.
  • Ensured the command is sensitive to these global flags:
    • --json — Forces machine-readable output to stdout.
    • --markdown — Forces Markdown output to stdout.
    • --dry-run — Resolves and validates the request without sending a mutation.
    • --quiet — Suppresses successful output and unessential guidance.
  • Added safe debug logging using the logger from the command context.
  • Regenerated the root command screenshot with make gen/screenshot.
  • Reviewed autocomplete behavior. Repository, connection, branch, and version values do not have reliable local autocomplete, so autocomplete is not provided for them.

PCI review checklist

  • I have documented a clear reason for, and description of, the change I am making.
  • Reverting this pull request fully removes the command. There are no migrations or persisted local state changes.
  • This change does not modify security controls. Request bodies and VCS connection IDs are not included in command output or logs.

@paladin-devops paladin-devops left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some pretty minor feedback, great job adding this command!

Comment on lines +17 to +21
ShortHelp: "Manage private registry modules.",
LongHelp: heredoc.New(inv.IO).Mustf(`
The {{ template "mdCodeOrBold" "%s module" }} command group lets you manage
private registry modules in HCP Terraform and Terraform Enterprise.
`, version.Name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good description and short help text. 👍🏻

Comment on lines +251 to +255
if opts.Quiet {
logger.Debug("Quiet mode enabled, rendering skipped")
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should drain the body before returning here. This way, if the server were to have keep-alives enabled, then the TCP connection could be returned to the connection pool, instead of being closed, which is the default behavior of Go's HTTP implementation for undrained response body.

Suggested change
if opts.Quiet {
logger.Debug("Quiet mode enabled, rendering skipped")
return nil
}
if opts.Quiet {
logger.Debug("Quiet mode enabled, rendering skipped")
io.Copy(io.Discard, resp.Body)
return nil
}

@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: "Added `tfctl module publish` for publishing VCS-backed private registry modules from existing OAuth or GitHub App connections"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Present-tense changelog note.

Suggested change
body: "Added `tfctl module publish` for publishing VCS-backed private registry modules from existing OAuth or GitHub App connections"
body: "tfctl now has `tfctl module publish` for publishing VCS-backed private registry modules from existing OAuth or GitHub App connections."

if response.Data.Links.Self != "" {
result.SelfLink, err = resolvePublishSelfLink(opts.Client.BaseURL, response.Data.Links.Self)
if err != nil {
return fmt.Errorf("failed to resolve registry module self link: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a test to publish_test.go for this error case.

return fields
}

func resolvePublishSelfLink(base *url.URL, self string) (string, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we could add some tests to publish_test.go, maybe in a new function TestResolvePublishSelfLink, which just tests this function's paths, rather than doing so implicitly via TestRunPublishOutputFormats.

})
},
})
opts.Quiet = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Quiet need to be true for this test?

@brandonc brandonc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoping to have a conversation about a potential --provider argument and a couple of usability ideas. Everything looks great

LongHelp: heredoc.New(inv.IO, heredoc.WithPreserveNewlines()).Mustf(`
The {{ template "mdCodeOrBold" "%s module publish" }} command publishes a private registry module from an existing VCS connection.

Provide exactly one of {{ template "mdCodeOrBold" "--oauth-token-id" }} or {{ template "mdCodeOrBold" "--github-app-installation-id" }}.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you say is the best way to identify the token ID that you should use for this command? I used the api command to navigate around this area but I'm wondering if there could be usability gains by supporting a service-provider or oauth client name.

Imagine:

$ tfctl module publish brandonc/terraform-aws-bootstrap --provider github

If "github" could be used to disambiguate the list of available providers, matching only one, you could then navigate to whichever token we show on the VCS providers screen.

Fallback option:

$ tfctl module publish brandonc/terraform-aws-bootstrap --oauth-token-id ot-...

I've also discovered that it's not possible to discover the github app installation ID because that endpoint requires cookie authentication. Can we provide some guidance about how to get the --github-app-installation-id?

return nil
}

type publishRequestEnvelope struct {

@brandonc brandonc Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love if you could provide some comments here about how these types (and the call to client.Do) are a substitute for missing OpenAPI definition -- it could be migrated to a native Client API call eventually and I don't want to lose sight of that.

}

if opts.Quiet {
logger.Debug("Quiet mode enabled, rendering skipped")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's humorous because this can never be logged by tfctl but I like the completeness. Some day there may be a file log or something.

Comment on lines +229 to +234
logger.Debug("Publishing VCS-backed registry module",
"method", http.MethodPost,
"path", requestURL.Path,
"organization", organization,
"mode", publishingMode,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI the method and path are debug logged by the API Client:

10:28:00.836 [DEBUG] tfctl.module publish: Publishing VCS-backed registry module: method=POST path=/api/v2/organizations/bcroft/registry-modules/vcs organization=bcroft mode=branch-based
10:28:00.836 [DEBUG] tfctl.module publish: HTTP request: method=POST url=https://app.staging.terraform.io/api/v2/organizations/bcroft/registry-modules/vcs

Namespace string `json:"namespace,omitempty"`
Provider string `json:"provider,omitempty"`
Status string `json:"status,omitempty"`
SelfLink string `json:"self_link,omitempty"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it might be useful to add an html_link here if you can derive it from the response so I can ctrl-click and navigate to it after the command runs.

/app/bcroft/registry/modules/private/ORGANIZATION/NAME/PROVIDER and let the server redirect to the version or show the status

While pending, the UI shows:

Waiting for module MODULENAME to become ready…

This page will automatically refresh.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants