From 3db5a4396fc34f87a57578ae0162493e4caabd0e Mon Sep 17 00:00:00 2001 From: Naveen Narayanan Date: Sun, 14 Dec 2025 20:01:06 -0800 Subject: [PATCH 1/4] Allow speculative run --- image/entrypoints/plan.sh | 13 ++++++++++--- terraform-plan/README.md | 20 ++++++++++++++++++++ terraform-plan/action.yaml | 12 ++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/image/entrypoints/plan.sh b/image/entrypoints/plan.sh index 7f9a053d..9278a335 100755 --- a/image/entrypoints/plan.sh +++ b/image/entrypoints/plan.sh @@ -11,15 +11,22 @@ set-plan-args exec 3>&1 ### Generate a plan -PLAN_OUT="$STEP_TMP_DIR/plan.out" +if [[ "$INPUT_SPECULATIVE" == "true" ]]; then + # Force speculative plan - no -out flag + PLAN_OUT="" +else + # Normal behavior - try to save plan file + PLAN_OUT="$STEP_TMP_DIR/plan.out" +fi PLAN_ARGS="$PLAN_ARGS -lock=false" plan -if [[ $PLAN_EXIT -eq 1 ]]; then +# If plan failed because remote backend doesn't support -out flag, retry without it +# Skip this retry if we're already running speculative (PLAN_OUT is already empty) +if [[ $PLAN_EXIT -eq 1 && -n "$PLAN_OUT" ]]; then if grep -q "Saving a generated plan is currently not supported" "$STEP_TMP_DIR/terraform_plan.stderr"; then # This terraform module is using the remote backend, which is deficient. PLAN_OUT="" - PLAN_ARGS="$PLAN_ARGS -lock=false" plan fi fi diff --git a/terraform-plan/README.md b/terraform-plan/README.md index baccb74b..d915f802 100644 --- a/terraform-plan/README.md +++ b/terraform-plan/README.md @@ -170,6 +170,24 @@ The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/ - Optional - Default: The Terraform default (10). +* `speculative` + + Set to `true` to force a speculative plan that cannot be applied. + + This creates a "Planned and finished" run in Terraform Cloud instead of "Planned and saved". + Speculative plans don't lock state and can run in parallel with other operations. + + This is useful for PR workflows where you want to preview changes without blocking other runs. + + ```yaml + with: + speculative: true + ``` + + - Type: boolean + - Optional + - Default: `false` + ## Outputs * `changes` @@ -185,6 +203,8 @@ The [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/ The plan can be used as the `plan_file` input to the [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) action. + This won't be set if `speculative` is `true` or if the backend type is `remote`/`cloud` in remote execution mode. + Terraform plans often contain sensitive information, so this output should be treated with care. - Type: string diff --git a/terraform-plan/action.yaml b/terraform-plan/action.yaml index 89a18ff4..e59e5c1e 100644 --- a/terraform-plan/action.yaml +++ b/terraform-plan/action.yaml @@ -78,6 +78,16 @@ inputs: description: Limit the number of concurrent operations required: false default: "0" + speculative: + description: | + Set to `true` to force a speculative plan that cannot be applied. + + This creates a "Planned and finished" run in Terraform Cloud instead of "Planned and saved". + Speculative plans don't lock state and can run in parallel with other operations. + + This is useful for PR workflows where you want to preview changes without blocking other runs. + required: false + default: "false" outputs: changes: @@ -89,6 +99,8 @@ outputs: The plan can be used as the `plan_file` input to the [dflook/terraform-apply](https://github.com/dflook/terraform-github-actions/tree/main/terraform-apply) action. + This won't be set if `speculative` is `true` or if the backend type is `remote`/`cloud` in remote execution mode. + Terraform plans often contain sensitive information, so this output should be treated with care. json_plan_path: description: | From b00fc8621a539063df88150ca5bf154e09a4f7e9 Mon Sep 17 00:00:00 2001 From: Naveen Narayanan Date: Sun, 14 Dec 2025 20:23:55 -0800 Subject: [PATCH 2/4] Exposing the comment url --- image/src/github_pr_comment/__main__.py | 3 +++ terraform-plan/action.yaml | 4 ++++ tofu-plan/action.yaml | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/image/src/github_pr_comment/__main__.py b/image/src/github_pr_comment/__main__.py index de4be08b..d789e497 100644 --- a/image/src/github_pr_comment/__main__.py +++ b/image/src/github_pr_comment/__main__.py @@ -543,6 +543,9 @@ def main() -> int: status=status ) + if comment.comment_url: + output('comment_url', comment.comment_url) + elif sys.argv[1] == 'status': if comment.comment_url is None: debug("Can't set status of comment that doesn't exist") diff --git a/terraform-plan/action.yaml b/terraform-plan/action.yaml index e59e5c1e..1f35f8b2 100644 --- a/terraform-plan/action.yaml +++ b/terraform-plan/action.yaml @@ -124,6 +124,10 @@ outputs: description: The number of resources that would be affected by this operation. run_id: description: If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. + comment_url: + description: | + The URL of the GitHub PR comment that was created or updated with the plan. + This will only be set if a comment was created (i.e., when running on a pull request with add_github_comment enabled). runs: using: docker diff --git a/tofu-plan/action.yaml b/tofu-plan/action.yaml index 6b8b67a2..cd11e154 100644 --- a/tofu-plan/action.yaml +++ b/tofu-plan/action.yaml @@ -120,6 +120,10 @@ outputs: description: The number of resources that would be affected by this operation. run_id: description: If the root module uses the `remote` or `cloud` backend in remote execution mode, this output will be set to the remote run id. + comment_url: + description: | + The URL of the GitHub PR comment that was created or updated with the plan. + This will only be set if a comment was created (i.e., when running on a pull request with add_github_comment enabled). runs: env: From 8b217cae877ee177c2af80211e5a459b9f769370 Mon Sep 17 00:00:00 2001 From: bennymagid Date: Thu, 23 Apr 2026 14:37:03 -0400 Subject: [PATCH 3/4] fix: truncate long lines before tfmask to prevent bufio.Scanner crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tfmask is a Go binary that reads plan output line-by-line using bufio.Scanner, which has a default 64KB buffer limit. Terraform resources that embed large base64 blobs in their plan output (e.g. google_api_gateway_api_config with openapi_documents) produce single lines that exceed this limit. When tfmask crashes mid-pipe, terraform receives SIGPIPE and exits with a non-standard code (not 0 or 2). PIPESTATUS[0] then captures that bad code, neither branch of the PLAN_EXIT check matches, set_output changes is never called, and the apply step is skipped with "No plan changes detected". Fix: add a Python pre-processor before tfmask that truncates any line exceeding 65000 chars. This preserves tfmask's secret-masking for all normal lines while preventing it from crashing on oversized blob lines. The truncated content only affects the human-readable plan display in plan.txt — it has no effect on whether changes are detected or applied. Co-Authored-By: Claude Sonnet 4.6 --- image/actions.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/image/actions.sh b/image/actions.sh index 080a3ca9..d64b49d9 100644 --- a/image/actions.sh +++ b/image/actions.sh @@ -505,6 +505,17 @@ function plan() { # shellcheck disable=SC2086 (cd "$INPUT_PATH" && $TOOL_COMMAND_NAME plan -input=false -no-color -detailed-exitcode -lock-timeout=300s $PARALLEL_ARG $PLAN_OUT_ARG $PLAN_ARGS) \ 2>"$STEP_TMP_DIR/terraform_plan.stderr" \ + | python3 -c " +import sys +# tfmask uses bufio.Scanner which has a 64KB line limit. Resources that embed +# large base64 blobs (e.g. google_api_gateway_api_config openapi_documents) +# produce lines that exceed this limit, causing tfmask to crash mid-pipe and +# terraform to exit via SIGPIPE with a non-standard exit code. That prevents +# PIPESTATUS[0] from returning 2 (changes), so the apply step is skipped. +# Truncating lines here before they reach tfmask prevents the crash. +for line in sys.stdin: + sys.stdout.write(line[:65000] + ' [line truncated for display]\n' if len(line) > 65000 else line) +" \ | $TFMASK \ | tee /dev/fd/3 "$STEP_TMP_DIR/terraform_plan.stdout" \ | compact_plan \ From 78e6d88fe472e256efc8df1f19abe26f3bd2ba39 Mon Sep 17 00:00:00 2001 From: bennymagid Date: Thu, 23 Apr 2026 16:25:21 -0400 Subject: [PATCH 4/4] fix: chunk long lines through tfmask and reassemble to preserve full content Instead of truncating lines that exceed bufio.Scanner's 64KB limit, split them into 60KB chunks with a sentinel prefix (##TF_CHUNK:i/total/content) before tfmask, then reassemble after. This prevents the tfmask crash while keeping the complete plan output intact for downstream consumers. --- image/actions.sh | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/image/actions.sh b/image/actions.sh index d64b49d9..9fdff02c 100644 --- a/image/actions.sh +++ b/image/actions.sh @@ -512,11 +512,37 @@ import sys # produce lines that exceed this limit, causing tfmask to crash mid-pipe and # terraform to exit via SIGPIPE with a non-standard exit code. That prevents # PIPESTATUS[0] from returning 2 (changes), so the apply step is skipped. -# Truncating lines here before they reach tfmask prevents the crash. +# Split long lines into chunks with a sentinel prefix so tfmask can process +# each chunk; the unchunker below reassembles them preserving the full content. +CHUNK = 60000 for line in sys.stdin: - sys.stdout.write(line[:65000] + ' [line truncated for display]\n' if len(line) > 65000 else line) + s = line.rstrip('\n') + if len(s) <= CHUNK: + sys.stdout.write(line) + else: + parts = [s[i:i+CHUNK] for i in range(0, len(s), CHUNK)] + for i, p in enumerate(parts): + sys.stdout.write(f'##TF_CHUNK:{i}/{len(parts)}/{p}\n') " \ | $TFMASK \ + | python3 -c " +import sys +# Reassemble lines that were split by the chunker above. +buf = [] +for line in sys.stdin: + s = line.rstrip('\n') + if s.startswith('##TF_CHUNK:'): + rest = s[11:] + slash1 = rest.index('/') + slash2 = rest.index('/', slash1 + 1) + i, total = int(rest[:slash1]), int(rest[slash1+1:slash2]) + buf.append((i, rest[slash2+1:])) + if len(buf) == total: + sys.stdout.write(''.join(p[1] for p in sorted(buf)) + '\n') + buf = [] + else: + sys.stdout.write(line) +" \ | tee /dev/fd/3 "$STEP_TMP_DIR/terraform_plan.stdout" \ | compact_plan \ >"$STEP_TMP_DIR/plan.txt"