From 93d28e365e6a341eb0bf64adc8ed957362e71d57 Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:25:32 +0900 Subject: [PATCH 1/7] feat: Custom template --- src/main.ts | 12 ++++++---- src/terraform.ts | 15 ++++++++++--- src/tfcmt.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index a6efc6a..a1a7c9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { validateRequirements, } from './pr-validation'; import { executeTerraformWithTfcmt, validateTerraformInstalled } from './terraform'; -import { setupTfcmt } from './tfcmt'; +import { setupTfcmt, writeTfcmtConfig } from './tfcmt'; import type { ProjectConfig, PullRequestInfo, TerraformCommand } from './types'; /** @@ -92,6 +92,7 @@ async function run(): Promise { // Setup tfcmt const tfcmtPath = await setupTfcmt(); + const tfcmtConfigPath = writeTfcmtConfig(); // Execute terraform for each target project serially for (const projectName of targetProjectNames) { @@ -104,7 +105,8 @@ async function run(): Promise { command, args, pr, - tfcmtPath + tfcmtPath, + tfcmtConfigPath ); } @@ -131,7 +133,8 @@ async function executeProjectCommand( command: 'plan' | 'apply', args: string[], pr: PullRequestInfo | null, - tfcmtPath: string + tfcmtPath: string, + tfcmtConfigPath: string ): Promise { core.info(`\n${'='.repeat(60)}`); core.info(`Project: ${project.name}`); @@ -175,7 +178,8 @@ async function executeProjectCommand( project.name, workingDir, args, - planFilePath + planFilePath, + tfcmtConfigPath ); // Log results and upload plan file if this was a plan command diff --git a/src/terraform.ts b/src/terraform.ts index 711ebb4..47b4dfc 100644 --- a/src/terraform.ts +++ b/src/terraform.ts @@ -33,7 +33,8 @@ export async function executeTerraform( workingDir: string, projectName: string, additionalArgs: string[] = [], - planFilePath?: string + planFilePath?: string, + tfcmtConfigPath?: string ): Promise { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.info(`Executing terraform ${command}${argsStr} in ${workingDir}`); @@ -41,6 +42,12 @@ export async function executeTerraform( // Build tfcmt arguments: tfcmt [flags] -var "target:" plan|apply -- terraform [command] [args] const tfcmtArgs: string[] = []; + // Pass custom config file if provided (-config must come first) + if (tfcmtConfigPath) { + tfcmtArgs.push('-config'); + tfcmtArgs.push(tfcmtConfigPath); + } + // Add target variable for monorepo support // This will prefix PR labels and comment titles with the project name tfcmtArgs.push('-var'); @@ -141,7 +148,8 @@ export async function executeTerraformWithTfcmt( projectName: string, workingDir: string, additionalArgs: string[] = [], - planFilePath?: string + planFilePath?: string, + tfcmtConfigPath?: string ): Promise { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.startGroup(`Executing terraform ${command}${argsStr} for project: ${projectName}`); @@ -153,7 +161,8 @@ export async function executeTerraformWithTfcmt( workingDir, projectName, additionalArgs, - planFilePath + planFilePath, + tfcmtConfigPath ); } finally { core.endGroup(); diff --git a/src/tfcmt.ts b/src/tfcmt.ts index c652b95..7cf7925 100644 --- a/src/tfcmt.ts +++ b/src/tfcmt.ts @@ -8,6 +8,64 @@ import * as path from 'node:path'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; +/** + * The custom tfcmt plan template. + * + * Renders the plan output and appends a "Run this plan again" hint using the + * project name from the `target` tfcmt variable (passed via `-var "target:"`). + */ +const PLAN_TEMPLATE = ` +{{- $target := index .Vars "target" -}} +{{- if eq .ExitCode 1 -}} +## ❌ Terraform Plan Failed{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- else if eq .ExitCode 0 -}} +## ✅ Plan: No Changes{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- else -}} +## 📋 Terraform Plan{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- end }} + +{{ if .Result -}} +
Show Plan + +{{ .Result }} + +
+{{- end }} +{{ if .ChangeOutsideTerraform -}} +
⚠️ Changes Outside Terraform + +{{ .ChangeOutsideTerraform }} + +
+{{- end }} +{{ if .Warning }} +> ⚠️ {{ .Warning }} +{{ end }} +--- +**Run this plan again:** +\`\`\` +terraform plan{{ if $target }} -project={{ $target }}{{ end }} +\`\`\` +{{ if .Link }}[CI Details]({{ .Link }}){{ end }} +`.trimStart(); + +/** + * Writes a tfcmt configuration file with a custom plan template to a temp + * directory and returns the path to it. + */ +export function writeTfcmtConfig(): string { + const configContent = `terraform: + plan: + template: | +${PLAN_TEMPLATE.split('\n').map((line) => ` ${line}`).join('\n')} +`; + + const configPath = path.join(os.tmpdir(), '.tfcmt-action.yml'); + fs.writeFileSync(configPath, configContent, 'utf8'); + core.info(`tfcmt config written to ${configPath}`); + return configPath; +} + /** * Maps Node.js platform to tfcmt platform naming */ From 4b396e4cb6ba4d8e78e42bad1ba0ea518b377f04 Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:27:43 +0900 Subject: [PATCH 2/7] build --- dist/index.js | 110 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 10 deletions(-) diff --git a/dist/index.js b/dist/index.js index c9f6b88..9780567 100644 --- a/dist/index.js +++ b/dist/index.js @@ -83130,6 +83130,12 @@ function validateConfig(config) { names.add(project.name); } const validated = { projects }; + if (c.automerge !== undefined) { + if (typeof c.automerge !== 'boolean') { + throw new Error('Configuration "automerge" must be a boolean'); + } + validated.automerge = c.automerge; + } return validated; } /** @@ -83249,8 +83255,17 @@ async function run() { let targetProjectNames = config.projects.map((p) => p.name); let command = 'plan'; let args = []; - // Extract comment body - if (github.context.eventName === 'issue_comment') { + // Handle automerge: when a PR is merged and automerge is enabled, apply all projects + if ((0, pr_validation_1.isPRMerged)(github.context)) { + if (!config.automerge) { + core.info('PR merged but automerge is not enabled in config, skipping'); + return; + } + core.info('PR merged with automerge enabled — running apply for all projects'); + command = 'apply'; + } + else if (github.context.eventName === 'issue_comment') { + // Extract comment body const commentBody = (0, pr_validation_1.getCommentBodyFromContext)(github.context); core.info(`Processing comment: ${commentBody}`); // Parse comment @@ -83276,13 +83291,14 @@ async function run() { } // Setup tfcmt const tfcmtPath = await (0, tfcmt_1.setupTfcmt)(); + const tfcmtConfigPath = (0, tfcmt_1.writeTfcmtConfig)(); // Execute terraform for each target project serially for (const projectName of targetProjectNames) { const project = config.projects.find((p) => p.name === projectName); if (!project) { throw new Error(`Project not found: ${projectName}`); } - await executeProjectCommand(project, command, args, pr, tfcmtPath); + await executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath); } core.info('Terraform PR Comment Action completed successfully'); } @@ -83302,7 +83318,7 @@ async function run() { * @param tfcmtPath - Path to tfcmt binary * @param tfcmtConfig - Optional tfcmt configuration */ -async function executeProjectCommand(project, command, args, pr, tfcmtPath) { +async function executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath) { core.info(`\n${'='.repeat(60)}`); core.info(`Project: ${project.name}`); core.info(`Directory: ${project.dir}`); @@ -83331,7 +83347,7 @@ async function executeProjectCommand(project, command, args, pr, tfcmtPath) { } } // Execute terraform with tfcmt - const result = await (0, terraform_1.executeTerraformWithTfcmt)(tfcmtPath, command, project.name, workingDir, args, planFilePath); + const result = await (0, terraform_1.executeTerraformWithTfcmt)(tfcmtPath, command, project.name, workingDir, args, planFilePath, tfcmtConfigPath); // Log results and upload plan file if this was a plan command if (command === 'plan') { if (result.hasChanges) { @@ -83406,6 +83422,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getPullRequestInfo = getPullRequestInfo; exports.validateRequirements = validateRequirements; exports.validateEventType = validateEventType; +exports.isPRMerged = isPRMerged; exports.getPRNumberFromContext = getPRNumberFromContext; exports.getCommentBodyFromContext = getCommentBodyFromContext; const core = __importStar(__nccwpck_require__(59550)); @@ -83498,6 +83515,16 @@ function validateEventType(eventName) { throw new Error(`This action is designed for issue_comment or pull_request events, but was triggered by: ${eventName}`); } } +/** + * Returns true when the current context is a merged pull_request event. + * + * @param context - GitHub context + */ +function isPRMerged(context) { + return (context.eventName === 'pull_request' && + context.payload.action === 'closed' && + context.payload.pull_request?.merged === true); +} /** * Extracts PR number from the GitHub context * @@ -83506,10 +83533,12 @@ function validateEventType(eventName) { * @throws Error if PR number cannot be determined */ function getPRNumberFromContext(context) { - const prNumber = context.payload.issue?.number; + // issue_comment events expose the PR number via payload.issue.number + // pull_request events expose it via payload.pull_request.number + const prNumber = context.payload.issue?.number ?? context.payload.pull_request?.number; if (!prNumber) { throw new Error('Could not determine PR number from context. ' + - 'Ensure this action is triggered by an issue_comment event on a pull request.'); + 'Ensure this action is triggered by an issue_comment or pull_request event on a pull request.'); } return prNumber; } @@ -83599,11 +83628,16 @@ const exec = __importStar(__nccwpck_require__(24154)); * - For plan commands, saves plan file to /tfplan- * - For apply commands, uses provided planFilePath if available */ -async function executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs = [], planFilePath) { +async function executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs = [], planFilePath, tfcmtConfigPath) { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.info(`Executing terraform ${command}${argsStr} in ${workingDir}`); // Build tfcmt arguments: tfcmt [flags] -var "target:" plan|apply -- terraform [command] [args] const tfcmtArgs = []; + // Pass custom config file if provided (-config must come first) + if (tfcmtConfigPath) { + tfcmtArgs.push('-config'); + tfcmtArgs.push(tfcmtConfigPath); + } // Add target variable for monorepo support // This will prefix PR labels and comment titles with the project name tfcmtArgs.push('-var'); @@ -83686,11 +83720,11 @@ async function executeTerraform(tfcmtPath, command, workingDir, projectName, add * @remarks * Executes terraform wrapped with tfcmt for automatic PR comment posting */ -async function executeTerraformWithTfcmt(tfcmtPath, command, projectName, workingDir, additionalArgs = [], planFilePath) { +async function executeTerraformWithTfcmt(tfcmtPath, command, projectName, workingDir, additionalArgs = [], planFilePath, tfcmtConfigPath) { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.startGroup(`Executing terraform ${command}${argsStr} for project: ${projectName}`); try { - return await executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs, planFilePath); + return await executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs, planFilePath, tfcmtConfigPath); } finally { core.endGroup(); @@ -83757,12 +83791,68 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.writeTfcmtConfig = writeTfcmtConfig; exports.setupTfcmt = setupTfcmt; const fs = __importStar(__nccwpck_require__(73024)); const os = __importStar(__nccwpck_require__(48161)); const path = __importStar(__nccwpck_require__(76760)); const core = __importStar(__nccwpck_require__(59550)); const tc = __importStar(__nccwpck_require__(42493)); +/** + * The custom tfcmt plan template. + * + * Renders the plan output and appends a "Run this plan again" hint using the + * project name from the `target` tfcmt variable (passed via `-var "target:"`). + */ +const PLAN_TEMPLATE = ` +{{- $target := index .Vars "target" -}} +{{- if eq .ExitCode 1 -}} +## ❌ Terraform Plan Failed{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- else if eq .ExitCode 0 -}} +## ✅ Plan: No Changes{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- else -}} +## 📋 Terraform Plan{{ if $target }} (\`{{ $target }}\`){{ end }} +{{- end }} + +{{ if .Result -}} +
Show Plan + +{{ .Result }} + +
+{{- end }} +{{ if .ChangeOutsideTerraform -}} +
⚠️ Changes Outside Terraform + +{{ .ChangeOutsideTerraform }} + +
+{{- end }} +{{ if .Warning }} +> ⚠️ {{ .Warning }} +{{ end }} +--- +**Run this plan again:** +\`\`\` +terraform plan{{ if $target }} -project={{ $target }}{{ end }} +\`\`\` +{{ if .Link }}[CI Details]({{ .Link }}){{ end }} +`.trimStart(); +/** + * Writes a tfcmt configuration file with a custom plan template to a temp + * directory and returns the path to it. + */ +function writeTfcmtConfig() { + const configContent = `terraform: + plan: + template: | +${PLAN_TEMPLATE.split('\n').map((line) => ` ${line}`).join('\n')} +`; + const configPath = path.join(os.tmpdir(), '.tfcmt-action.yml'); + fs.writeFileSync(configPath, configContent, 'utf8'); + core.info(`tfcmt config written to ${configPath}`); + return configPath; +} /** * Maps Node.js platform to tfcmt platform naming */ From 786c6feca1a75e2ed4d0e6df32d7148483a43f8c Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:32:55 +0900 Subject: [PATCH 3/7] full template --- dist/index.js | 175 +++++++++++++++++++++++++++++++++++++------------- src/tfcmt.ts | 172 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 256 insertions(+), 91 deletions(-) diff --git a/dist/index.js b/dist/index.js index 9780567..1631b88 100644 --- a/dist/index.js +++ b/dist/index.js @@ -83799,57 +83799,140 @@ const path = __importStar(__nccwpck_require__(76760)); const core = __importStar(__nccwpck_require__(59550)); const tc = __importStar(__nccwpck_require__(42493)); /** - * The custom tfcmt plan template. - * - * Renders the plan output and appends a "Run this plan again" hint using the - * project name from the `target` tfcmt variable (passed via `-var "target:"`). - */ -const PLAN_TEMPLATE = ` -{{- $target := index .Vars "target" -}} -{{- if eq .ExitCode 1 -}} -## ❌ Terraform Plan Failed{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- else if eq .ExitCode 0 -}} -## ✅ Plan: No Changes{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- else -}} -## 📋 Terraform Plan{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- end }} - -{{ if .Result -}} -
Show Plan - -{{ .Result }} - -
-{{- end }} -{{ if .ChangeOutsideTerraform -}} -
⚠️ Changes Outside Terraform - -{{ .ChangeOutsideTerraform }} - -
-{{- end }} -{{ if .Warning }} -> ⚠️ {{ .Warning }} -{{ end }} ---- -**Run this plan again:** -\`\`\` -terraform plan{{ if $target }} -project={{ $target }}{{ end }} -\`\`\` -{{ if .Link }}[CI Details]({{ .Link }}){{ end }} + * Full tfcmt configuration based on the upstream default, with the plan + * template extended to show a "Run this plan again" command using -project=. + * + * @see https://suzuki-shunsuke.github.io/tfcmt/config/#default-configuration + */ +const TFCMT_CONFIG = ` +embedded_var_names: [] + +templates: + plan_title: "## {{if eq .ExitCode 1}}:x: {{end}}Plan Result{{if .Vars.target}} ({{.Vars.target}}){{end}}" + apply_title: "## :{{if eq .ExitCode 0}}white_check_mark{{else}}x{{end}}: Apply Result{{if .Vars.target}} ({{.Vars.target}}){{end}}" + result: "{{if .Result}}
{{ .Result }}
{{end}}" + updated_resources: | + {{if .CreatedResources}} + * Create + {{- range .CreatedResources}} + * {{.}} + {{- end}}{{end}}{{if .UpdatedResources}} + * Update + {{- range .UpdatedResources}} + * {{.}} + {{- end}}{{end}}{{if .DeletedResources}} + * Delete + {{- range .DeletedResources}} + * {{.}} + {{- end}}{{end}}{{if .ReplacedResources}} + * Replace + {{- range .ReplacedResources}} + * {{.}} + {{- end}}{{end}}{{if .ImportedResources}} + * Import + {{- range .ImportedResources}} + * {{.}} + {{- end}}{{end}}{{if .MovedResources}} + * Move + {{- range .MovedResources}} + * {{.Before}} => {{.After}} + {{- end}}{{end}} + deletion_warning: | + {{if .HasDestroy}} + ### :warning: Resource Deletion will happen :warning: + This plan contains resource delete operation. Please check the plan result very carefully! + {{end}} + changed_result: | + {{if .ChangedResult}} +
Change Result (Click me) + {{wrapCode .ChangedResult}} +
+ {{end}} + change_outside_terraform: | + {{if .ChangeOutsideTerraform}} +
:information_source: Objects have changed outside of Terraform + _This feature was introduced from [Terraform v0.15.4](https://github.com/hashicorp/terraform/releases/tag/v0.15.4)._ + {{wrapCode .ChangeOutsideTerraform}} +
+ {{end}} + warning: | + {{if .Warning}} + ## :warning: Warnings :warning: + {{wrapCode .Warning}} + {{end}} + error_messages: | + {{if .ErrorMessages}} + ## :warning: Errors + {{range .ErrorMessages}} + * {{. -}} + {{- end}}{{end}} + guide_apply_failure: "" + guide_apply_parse_error: "" + +terraform: + plan: + disable_label: false + ignore_warning: false + template: | + {{template "plan_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{template "deletion_warning" .}} + {{template "result" .}} + {{template "updated_resources" .}} + {{template "changed_result" .}} + {{template "change_outside_terraform" .}} + {{template "warning" .}} + {{template "error_messages" .}} + {{if .Vars.target}} + --- + **Run this plan again:** \`terraform plan -project={{.Vars.target}}\` + {{end}} + when_add_or_update_only: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}add-or-update" + label_color: 1d76db + when_destroy: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}destroy" + label_color: d93f0b + when_no_changes: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}no-changes" + label_color: 0e8a16 + when_plan_error: + label: + label_color: + when_parse_error: + template: | + {{template "plan_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + It failed to parse the result. +
Details (Click me) + {{wrapCode .CombinedOutput}} +
+ apply: + template: | + {{template "apply_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{if ne .ExitCode 0}}{{template "guide_apply_failure" .}}{{end}} + {{template "result" .}} +
Details (Click me) + {{wrapCode .CombinedOutput}} +
+ {{template "error_messages" .}} + when_parse_error: + template: | + {{template "apply_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{template "guide_apply_parse_error" .}} + It failed to parse the result. +
Details (Click me) + {{wrapCode .CombinedOutput}} +
`.trimStart(); /** - * Writes a tfcmt configuration file with a custom plan template to a temp - * directory and returns the path to it. + * Writes a tfcmt configuration file to a temp directory and returns the path. */ function writeTfcmtConfig() { - const configContent = `terraform: - plan: - template: | -${PLAN_TEMPLATE.split('\n').map((line) => ` ${line}`).join('\n')} -`; const configPath = path.join(os.tmpdir(), '.tfcmt-action.yml'); - fs.writeFileSync(configPath, configContent, 'utf8'); + fs.writeFileSync(configPath, TFCMT_CONFIG, 'utf8'); core.info(`tfcmt config written to ${configPath}`); return configPath; } diff --git a/src/tfcmt.ts b/src/tfcmt.ts index 7cf7925..04c0015 100644 --- a/src/tfcmt.ts +++ b/src/tfcmt.ts @@ -9,59 +9,141 @@ import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; /** - * The custom tfcmt plan template. + * Full tfcmt configuration based on the upstream default, with the plan + * template extended to show a "Run this plan again" command using -project=. * - * Renders the plan output and appends a "Run this plan again" hint using the - * project name from the `target` tfcmt variable (passed via `-var "target:"`). + * @see https://suzuki-shunsuke.github.io/tfcmt/config/#default-configuration */ -const PLAN_TEMPLATE = ` -{{- $target := index .Vars "target" -}} -{{- if eq .ExitCode 1 -}} -## ❌ Terraform Plan Failed{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- else if eq .ExitCode 0 -}} -## ✅ Plan: No Changes{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- else -}} -## 📋 Terraform Plan{{ if $target }} (\`{{ $target }}\`){{ end }} -{{- end }} - -{{ if .Result -}} -
Show Plan - -{{ .Result }} - -
-{{- end }} -{{ if .ChangeOutsideTerraform -}} -
⚠️ Changes Outside Terraform - -{{ .ChangeOutsideTerraform }} - -
-{{- end }} -{{ if .Warning }} -> ⚠️ {{ .Warning }} -{{ end }} ---- -**Run this plan again:** -\`\`\` -terraform plan{{ if $target }} -project={{ $target }}{{ end }} -\`\`\` -{{ if .Link }}[CI Details]({{ .Link }}){{ end }} +const TFCMT_CONFIG = ` +embedded_var_names: [] + +templates: + plan_title: "## {{if eq .ExitCode 1}}:x: {{end}}Plan Result{{if .Vars.target}} ({{.Vars.target}}){{end}}" + apply_title: "## :{{if eq .ExitCode 0}}white_check_mark{{else}}x{{end}}: Apply Result{{if .Vars.target}} ({{.Vars.target}}){{end}}" + result: "{{if .Result}}
{{ .Result }}
{{end}}" + updated_resources: | + {{if .CreatedResources}} + * Create + {{- range .CreatedResources}} + * {{.}} + {{- end}}{{end}}{{if .UpdatedResources}} + * Update + {{- range .UpdatedResources}} + * {{.}} + {{- end}}{{end}}{{if .DeletedResources}} + * Delete + {{- range .DeletedResources}} + * {{.}} + {{- end}}{{end}}{{if .ReplacedResources}} + * Replace + {{- range .ReplacedResources}} + * {{.}} + {{- end}}{{end}}{{if .ImportedResources}} + * Import + {{- range .ImportedResources}} + * {{.}} + {{- end}}{{end}}{{if .MovedResources}} + * Move + {{- range .MovedResources}} + * {{.Before}} => {{.After}} + {{- end}}{{end}} + deletion_warning: | + {{if .HasDestroy}} + ### :warning: Resource Deletion will happen :warning: + This plan contains resource delete operation. Please check the plan result very carefully! + {{end}} + changed_result: | + {{if .ChangedResult}} +
Change Result (Click me) + {{wrapCode .ChangedResult}} +
+ {{end}} + change_outside_terraform: | + {{if .ChangeOutsideTerraform}} +
:information_source: Objects have changed outside of Terraform + _This feature was introduced from [Terraform v0.15.4](https://github.com/hashicorp/terraform/releases/tag/v0.15.4)._ + {{wrapCode .ChangeOutsideTerraform}} +
+ {{end}} + warning: | + {{if .Warning}} + ## :warning: Warnings :warning: + {{wrapCode .Warning}} + {{end}} + error_messages: | + {{if .ErrorMessages}} + ## :warning: Errors + {{range .ErrorMessages}} + * {{. -}} + {{- end}}{{end}} + guide_apply_failure: "" + guide_apply_parse_error: "" + +terraform: + plan: + disable_label: false + ignore_warning: false + template: | + {{template "plan_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{template "deletion_warning" .}} + {{template "result" .}} + {{template "updated_resources" .}} + {{template "changed_result" .}} + {{template "change_outside_terraform" .}} + {{template "warning" .}} + {{template "error_messages" .}} + {{if .Vars.target}} + --- + **Run this plan again:** \`terraform plan -project={{.Vars.target}}\` + {{end}} + when_add_or_update_only: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}add-or-update" + label_color: 1d76db + when_destroy: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}destroy" + label_color: d93f0b + when_no_changes: + label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}no-changes" + label_color: 0e8a16 + when_plan_error: + label: + label_color: + when_parse_error: + template: | + {{template "plan_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + It failed to parse the result. +
Details (Click me) + {{wrapCode .CombinedOutput}} +
+ apply: + template: | + {{template "apply_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{if ne .ExitCode 0}}{{template "guide_apply_failure" .}}{{end}} + {{template "result" .}} +
Details (Click me) + {{wrapCode .CombinedOutput}} +
+ {{template "error_messages" .}} + when_parse_error: + template: | + {{template "apply_title" .}} + {{if .Link}}[CI link]({{.Link}}){{end}} + {{template "guide_apply_parse_error" .}} + It failed to parse the result. +
Details (Click me) + {{wrapCode .CombinedOutput}} +
`.trimStart(); /** - * Writes a tfcmt configuration file with a custom plan template to a temp - * directory and returns the path to it. + * Writes a tfcmt configuration file to a temp directory and returns the path. */ export function writeTfcmtConfig(): string { - const configContent = `terraform: - plan: - template: | -${PLAN_TEMPLATE.split('\n').map((line) => ` ${line}`).join('\n')} -`; - const configPath = path.join(os.tmpdir(), '.tfcmt-action.yml'); - fs.writeFileSync(configPath, configContent, 'utf8'); + fs.writeFileSync(configPath, TFCMT_CONFIG, 'utf8'); core.info(`tfcmt config written to ${configPath}`); return configPath; } From c487c75ec20361dbe0ce1151b7dc8041a2fadd2d Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:53:44 +0900 Subject: [PATCH 4/7] apply command --- dist/index.js | 34 +++++++++++++++++++++++++--------- src/main.ts | 17 ++++++++++++----- src/terraform.ts | 16 +++++++++++++--- src/tfcmt.ts | 9 ++++++++- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/dist/index.js b/dist/index.js index 1631b88..9bc331b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -83283,10 +83283,13 @@ async function run() { command = parsedComment.command; args = parsedComment.args; } - // Get PR information + // Always resolve PR number — needed so tfcmt can post comments regardless + // of whether GITHUB_REF points to a PR ref (it doesn't for issue_comment + // or pull_request closed events) + const prNumber = (0, pr_validation_1.getPRNumberFromContext)(github.context); + // Get full PR information (only needed for apply requirements validation) let pr = null; if (command === 'apply') { - const prNumber = (0, pr_validation_1.getPRNumberFromContext)(github.context); pr = await (0, pr_validation_1.getPullRequestInfo)(token, github.context.repo.owner, github.context.repo.repo, prNumber); } // Setup tfcmt @@ -83298,7 +83301,7 @@ async function run() { if (!project) { throw new Error(`Project not found: ${projectName}`); } - await executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath); + await executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath, prNumber); } core.info('Terraform PR Comment Action completed successfully'); } @@ -83318,7 +83321,7 @@ async function run() { * @param tfcmtPath - Path to tfcmt binary * @param tfcmtConfig - Optional tfcmt configuration */ -async function executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath) { +async function executeProjectCommand(project, command, args, pr, tfcmtPath, tfcmtConfigPath, prNumber) { core.info(`\n${'='.repeat(60)}`); core.info(`Project: ${project.name}`); core.info(`Directory: ${project.dir}`); @@ -83347,7 +83350,7 @@ async function executeProjectCommand(project, command, args, pr, tfcmtPath, tfcm } } // Execute terraform with tfcmt - const result = await (0, terraform_1.executeTerraformWithTfcmt)(tfcmtPath, command, project.name, workingDir, args, planFilePath, tfcmtConfigPath); + const result = await (0, terraform_1.executeTerraformWithTfcmt)(tfcmtPath, command, project.name, workingDir, args, planFilePath, tfcmtConfigPath, prNumber); // Log results and upload plan file if this was a plan command if (command === 'plan') { if (result.hasChanges) { @@ -83628,7 +83631,7 @@ const exec = __importStar(__nccwpck_require__(24154)); * - For plan commands, saves plan file to /tfplan- * - For apply commands, uses provided planFilePath if available */ -async function executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs = [], planFilePath, tfcmtConfigPath) { +async function executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs = [], planFilePath, tfcmtConfigPath, prNumber) { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.info(`Executing terraform ${command}${argsStr} in ${workingDir}`); // Build tfcmt arguments: tfcmt [flags] -var "target:" plan|apply -- terraform [command] [args] @@ -83638,6 +83641,12 @@ async function executeTerraform(tfcmtPath, command, workingDir, projectName, add tfcmtArgs.push('-config'); tfcmtArgs.push(tfcmtConfigPath); } + // Explicitly pass PR number so tfcmt can post comments for issue_comment and + // pull_request closed events where GITHUB_REF is not a PR ref + if (prNumber !== undefined) { + tfcmtArgs.push('-pr'); + tfcmtArgs.push(String(prNumber)); + } // Add target variable for monorepo support // This will prefix PR labels and comment titles with the project name tfcmtArgs.push('-var'); @@ -83720,11 +83729,11 @@ async function executeTerraform(tfcmtPath, command, workingDir, projectName, add * @remarks * Executes terraform wrapped with tfcmt for automatic PR comment posting */ -async function executeTerraformWithTfcmt(tfcmtPath, command, projectName, workingDir, additionalArgs = [], planFilePath, tfcmtConfigPath) { +async function executeTerraformWithTfcmt(tfcmtPath, command, projectName, workingDir, additionalArgs = [], planFilePath, tfcmtConfigPath, prNumber) { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.startGroup(`Executing terraform ${command}${argsStr} for project: ${projectName}`); try { - return await executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs, planFilePath, tfcmtConfigPath); + return await executeTerraform(tfcmtPath, command, workingDir, projectName, additionalArgs, planFilePath, tfcmtConfigPath, prNumber); } finally { core.endGroup(); @@ -83885,7 +83894,14 @@ terraform: {{template "error_messages" .}} {{if .Vars.target}} --- - **Run this plan again:** \`terraform plan -project={{.Vars.target}}\` + **Run this plan again:** + \`\`\` + terraform plan -project={{.Vars.target}} + \`\`\` + **Apply this plan:** + \`\`\` + terraform apply -project={{.Vars.target}} + \`\`\` {{end}} when_add_or_update_only: label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}add-or-update" diff --git a/src/main.ts b/src/main.ts index a1a7c9a..f100ac0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -78,10 +78,14 @@ async function run(): Promise { args = parsedComment.args; } - // Get PR information + // Always resolve PR number — needed so tfcmt can post comments regardless + // of whether GITHUB_REF points to a PR ref (it doesn't for issue_comment + // or pull_request closed events) + const prNumber = getPRNumberFromContext(github.context); + + // Get full PR information (only needed for apply requirements validation) let pr: PullRequestInfo | null = null; if (command === 'apply') { - const prNumber = getPRNumberFromContext(github.context); pr = await getPullRequestInfo( token, github.context.repo.owner, @@ -106,7 +110,8 @@ async function run(): Promise { args, pr, tfcmtPath, - tfcmtConfigPath + tfcmtConfigPath, + prNumber ); } @@ -134,7 +139,8 @@ async function executeProjectCommand( args: string[], pr: PullRequestInfo | null, tfcmtPath: string, - tfcmtConfigPath: string + tfcmtConfigPath: string, + prNumber: number ): Promise { core.info(`\n${'='.repeat(60)}`); core.info(`Project: ${project.name}`); @@ -179,7 +185,8 @@ async function executeProjectCommand( workingDir, args, planFilePath, - tfcmtConfigPath + tfcmtConfigPath, + prNumber ); // Log results and upload plan file if this was a plan command diff --git a/src/terraform.ts b/src/terraform.ts index 47b4dfc..2a68543 100644 --- a/src/terraform.ts +++ b/src/terraform.ts @@ -34,7 +34,8 @@ export async function executeTerraform( projectName: string, additionalArgs: string[] = [], planFilePath?: string, - tfcmtConfigPath?: string + tfcmtConfigPath?: string, + prNumber?: number ): Promise { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.info(`Executing terraform ${command}${argsStr} in ${workingDir}`); @@ -48,6 +49,13 @@ export async function executeTerraform( tfcmtArgs.push(tfcmtConfigPath); } + // Explicitly pass PR number so tfcmt can post comments for issue_comment and + // pull_request closed events where GITHUB_REF is not a PR ref + if (prNumber !== undefined) { + tfcmtArgs.push('-pr'); + tfcmtArgs.push(String(prNumber)); + } + // Add target variable for monorepo support // This will prefix PR labels and comment titles with the project name tfcmtArgs.push('-var'); @@ -149,7 +157,8 @@ export async function executeTerraformWithTfcmt( workingDir: string, additionalArgs: string[] = [], planFilePath?: string, - tfcmtConfigPath?: string + tfcmtConfigPath?: string, + prNumber?: number ): Promise { const argsStr = additionalArgs.length > 0 ? ` ${additionalArgs.join(' ')}` : ''; core.startGroup(`Executing terraform ${command}${argsStr} for project: ${projectName}`); @@ -162,7 +171,8 @@ export async function executeTerraformWithTfcmt( projectName, additionalArgs, planFilePath, - tfcmtConfigPath + tfcmtConfigPath, + prNumber ); } finally { core.endGroup(); diff --git a/src/tfcmt.ts b/src/tfcmt.ts index 04c0015..3f76bf1 100644 --- a/src/tfcmt.ts +++ b/src/tfcmt.ts @@ -95,7 +95,14 @@ terraform: {{template "error_messages" .}} {{if .Vars.target}} --- - **Run this plan again:** \`terraform plan -project={{.Vars.target}}\` + **Run this plan again:** + \`\`\` + terraform plan -project={{.Vars.target}} + \`\`\` + **Apply this plan:** + \`\`\` + terraform apply -project={{.Vars.target}} + \`\`\` {{end}} when_add_or_update_only: label: "{{if .Vars.target}}{{.Vars.target}}/{{end}}add-or-update" From 6a0548348806e643f3aa4bd6c8658e65687924f9 Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:37:01 +0900 Subject: [PATCH 5/7] test --- .github/workflows/terraform-action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/terraform-action.yml b/.github/workflows/terraform-action.yml index da2de90..6d2b9ed 100644 --- a/.github/workflows/terraform-action.yml +++ b/.github/workflows/terraform-action.yml @@ -32,7 +32,7 @@ jobs: terraform_version: 1.7.0 - name: Run terraform-action on local - uses: ./ + uses: tkasuz/terraform-action@v1.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} config-path: examples/.terraform-action.yaml From a81a4e193add12c9f4acd6e20afd74a19af011b4 Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:40:40 +0900 Subject: [PATCH 6/7] test --- src/terraform.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/terraform.ts b/src/terraform.ts index 2a68543..a85b756 100644 --- a/src/terraform.ts +++ b/src/terraform.ts @@ -2,6 +2,8 @@ * Terraform execution logic */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import * as core from '@actions/core'; import * as exec from '@actions/exec'; @@ -94,8 +96,26 @@ export async function executeTerraform( let stdout = ''; let stderr = ''; + // Build env — when triggered by issue_comment the inherited GITHUB_EVENT_NAME + // is "issue_comment" and the event payload has no pull_request key, so tfcmt + // skips posting a PR comment. Override with a synthetic pull_request event so + // tfcmt always posts the comment regardless of the triggering event type. + const env: Record = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ); + if (prNumber !== undefined) { + const syntheticEvent = JSON.stringify({ number: prNumber, pull_request: { number: prNumber } }); + const eventPath = path.join(os.tmpdir(), `tfcmt-event-${prNumber}.json`); + fs.writeFileSync(eventPath, syntheticEvent); + env['GITHUB_EVENT_NAME'] = 'pull_request'; + env['GITHUB_EVENT_PATH'] = eventPath; + env['GITHUB_REF'] = `refs/pull/${prNumber}/merge`; + core.info(`Overriding GITHUB_EVENT_NAME=pull_request, GITHUB_EVENT_PATH=${eventPath}`); + } + const options: exec.ExecOptions = { cwd: workingDir, + env, ignoreReturnCode: true, listeners: { stdout: (data: Buffer) => { From b2d8d7b60e2becb66d9b351f816733e66a69bd1f Mon Sep 17 00:00:00 2001 From: tkasuz <63289889+tkasuz@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:44:25 +0900 Subject: [PATCH 7/7] use local --- .github/workflows/terraform-action.yml | 2 +- dist/index.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/terraform-action.yml b/.github/workflows/terraform-action.yml index 6d2b9ed..da2de90 100644 --- a/.github/workflows/terraform-action.yml +++ b/.github/workflows/terraform-action.yml @@ -32,7 +32,7 @@ jobs: terraform_version: 1.7.0 - name: Run terraform-action on local - uses: tkasuz/terraform-action@v1.1.0 + uses: ./ with: github-token: ${{ secrets.GITHUB_TOKEN }} config-path: examples/.terraform-action.yaml diff --git a/dist/index.js b/dist/index.js index 9bc331b..8f1f88f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -83608,6 +83608,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.executeTerraform = executeTerraform; exports.executeTerraformWithTfcmt = executeTerraformWithTfcmt; exports.validateTerraformInstalled = validateTerraformInstalled; +const fs = __importStar(__nccwpck_require__(73024)); +const os = __importStar(__nccwpck_require__(48161)); const path = __importStar(__nccwpck_require__(76760)); const core = __importStar(__nccwpck_require__(59550)); const exec = __importStar(__nccwpck_require__(24154)); @@ -83680,8 +83682,23 @@ async function executeTerraform(tfcmtPath, command, workingDir, projectName, add // Capture stdout and stderr let stdout = ''; let stderr = ''; + // Build env — when triggered by issue_comment the inherited GITHUB_EVENT_NAME + // is "issue_comment" and the event payload has no pull_request key, so tfcmt + // skips posting a PR comment. Override with a synthetic pull_request event so + // tfcmt always posts the comment regardless of the triggering event type. + const env = Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)); + if (prNumber !== undefined) { + const syntheticEvent = JSON.stringify({ number: prNumber, pull_request: { number: prNumber } }); + const eventPath = path.join(os.tmpdir(), `tfcmt-event-${prNumber}.json`); + fs.writeFileSync(eventPath, syntheticEvent); + env['GITHUB_EVENT_NAME'] = 'pull_request'; + env['GITHUB_EVENT_PATH'] = eventPath; + env['GITHUB_REF'] = `refs/pull/${prNumber}/merge`; + core.info(`Overriding GITHUB_EVENT_NAME=pull_request, GITHUB_EVENT_PATH=${eventPath}`); + } const options = { cwd: workingDir, + env, ignoreReturnCode: true, listeners: { stdout: (data) => {