Skip to content

fix: atomic plugin dir replacement to prevent stale executables on upgrade#645

Merged
intel352 merged 3 commits into
mainfrom
copilot/fix-stale-executable-issue
May 13, 2026
Merged

fix: atomic plugin dir replacement to prevent stale executables on upgrade#645
intel352 merged 3 commits into
mainfrom
copilot/fix-stale-executable-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 13, 2026

wfctl plugin install reported success after an upgrade but left the old binary in place, because extraction wrote into an existing directory and ensurePluginBinary short-circuited when destDir/<name> already existed — meaning a GoReleaser-named tarball entry was extracted but never renamed over the stale binary.

Root cause

// ensurePluginBinary — old code
if info, err := os.Stat(expectedPath); err == nil && !info.IsDir() {
    return nil // "already named correctly" — actually the OLD binary
}

All three install paths (installPluginFromManifest, installFromURL, installFromLocal) called os.MkdirAll then extracted on top of the existing directory, so the stale binary survived every upgrade.

Changes

  • preparePluginStagingDir(destDir) — creates a fresh destDir + ".installing" sibling (same filesystem, atomic-rename safe) and returns a cleanup func invoked on failure.
  • commitPluginStagingDir(stagingDir, destDir) — removes the old destDir then renames staging into its place; old install is untouched if commit fails.
  • verifyInstalledVersion(dir, expectedVersion) — asserts the staged plugin.json version matches the manifest before committing; catches silent write failures.
  • All three install paths now extract/copy into the staging directory, verify there, then commit atomically — ensurePluginBinary always runs against an empty staging dir so the stale-binary early-return can never fire.

Tests

Test What it proves
TestInstallPluginFromManifest_UpgradeReplacesStaleBinary GoReleaser-suffix tarball: binary content is replaced on upgrade
TestInstallFromURL_UpgradeReplacesStaleBinary Same invariant for --url path
TestInstallFromLocal_UpgradeReplacesStaleBinary Same invariant for --local path
TestInstallPluginFromManifest_StagingCleanedUpOnFailure Staging dir removed and original preserved when upgrade fails

Copilot AI requested review from Copilot and removed request for Copilot May 13, 2026 05:53
Copilot AI linked an issue May 13, 2026 that may be closed by this pull request
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:264: parsing iteration count: invalid syntax
baseline-bench.txt:291409: parsing iteration count: invalid syntax
baseline-bench.txt:558851: parsing iteration count: invalid syntax
baseline-bench.txt:1096044: parsing iteration count: invalid syntax
baseline-bench.txt:1386271: parsing iteration count: invalid syntax
baseline-bench.txt:1660442: parsing iteration count: invalid syntax
benchmark-results.txt:264: parsing iteration count: invalid syntax
benchmark-results.txt:338851: parsing iteration count: invalid syntax
benchmark-results.txt:645284: parsing iteration count: invalid syntax
benchmark-results.txt:962160: parsing iteration count: invalid syntax
benchmark-results.txt:1274704: parsing iteration count: invalid syntax
benchmark-results.txt:1605476: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │       benchmark-results.txt        │
                            │       sec/op       │    sec/op     vs base              │
InterpreterCreation-4               7.657m ± 59%   5.836m ± 77%       ~ (p=0.699 n=6)
ComponentLoad-4                     3.575m ±  0%   3.567m ±  1%       ~ (p=0.485 n=6)
ComponentExecute-4                  1.924µ ±  1%   1.961µ ±  0%  +1.92% (p=0.002 n=6)
PoolContention/workers-1-4          1.089µ ±  1%   1.088µ ±  2%       ~ (p=0.784 n=6)
PoolContention/workers-2-4          1.074µ ±  4%   1.084µ ±  1%       ~ (p=0.327 n=6)
PoolContention/workers-4-4          1.081µ ±  1%   1.089µ ±  1%       ~ (p=0.128 n=6)
PoolContention/workers-8-4          1.083µ ±  2%   1.096µ ±  1%  +1.11% (p=0.022 n=6)
PoolContention/workers-16-4         1.092µ ±  1%   1.098µ ±  0%       ~ (p=0.160 n=6)
ComponentLifecycle-4                3.644m ±  1%   3.594m ±  0%  -1.38% (p=0.026 n=6)
SourceValidation-4                  2.317µ ±  2%   2.316µ ±  0%       ~ (p=0.615 n=6)
RegistryConcurrent-4                807.9n ±  3%   783.9n ±  4%       ~ (p=0.132 n=6)
LoaderLoadFromString-4              3.659m ±  1%   3.598m ±  2%  -1.65% (p=0.015 n=6)
geomean                             18.81µ         18.37µ        -2.33%

                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │        B/op        │     B/op      vs base                │
InterpreterCreation-4               2.027Mi ± 0%   2.027Mi ± 0%       ~ (p=0.699 n=6)
ComponentLoad-4                     2.180Mi ± 0%   2.180Mi ± 0%       ~ (p=0.952 n=6)
ComponentExecute-4                  1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4         1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                2.183Mi ± 0%   2.183Mi ± 0%       ~ (p=0.459 n=6)
SourceValidation-4                  1.984Ki ± 0%   1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                1.133Ki ± 0%   1.133Ki ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4              2.182Mi ± 0%   2.182Mi ± 0%       ~ (p=0.366 n=6)
geomean                             15.25Ki        15.25Ki       -0.00%
¹ all samples are equal

                            │ baseline-bench.txt │        benchmark-results.txt        │
                            │     allocs/op      │  allocs/op   vs base                │
InterpreterCreation-4                15.68k ± 0%   15.68k ± 0%       ~ (p=1.000 n=6)
ComponentLoad-4                      18.02k ± 0%   18.02k ± 0%       ~ (p=1.000 n=6)
ComponentExecute-4                    25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4           25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                 18.07k ± 0%   18.07k ± 0%       ~ (p=1.000 n=6) ¹
SourceValidation-4                    32.00 ± 0%    32.00 ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                  2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4               18.06k ± 0%   18.06k ± 0%       ~ (p=1.000 n=6) ¹
geomean                               183.3         183.3       +0.00%
¹ all samples are equal

pkg: github.com/GoCodeAlone/workflow/middleware
                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │       sec/op       │    sec/op     vs base              │
CircuitBreakerDetection-4                  286.7n ± 5%   289.3n ± 11%       ~ (p=0.784 n=6)
CircuitBreakerExecution_Success-4          21.38n ± 1%   21.53n ±  0%  +0.68% (p=0.043 n=6)
CircuitBreakerExecution_Failure-4          66.81n ± 0%   66.34n ±  0%  -0.71% (p=0.002 n=6)
geomean                                    74.26n        74.48n        +0.29%

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │        B/op        │    B/op     vs base                │
CircuitBreakerDetection-4                 144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │     allocs/op      │ allocs/op   vs base                │
CircuitBreakerDetection-4                 1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │       sec/op       │    sec/op     vs base              │
JQTransform_Simple-4                     869.8n ± 27%   866.5n ± 25%       ~ (p=0.937 n=6)
JQTransform_ObjectConstruction-4         1.434µ ±  2%   1.450µ ±  2%  +1.15% (p=0.035 n=6)
JQTransform_ArraySelect-4                3.325µ ±  2%   3.325µ ±  1%       ~ (p=0.723 n=6)
JQTransform_Complex-4                    38.52µ ±  1%   37.73µ ±  2%  -2.06% (p=0.015 n=6)
JQTransform_Throughput-4                 1.760µ ±  1%   1.776µ ±  0%  +0.88% (p=0.002 n=6)
SSEPublishDelivery-4                     62.74n ±  1%   63.20n ±  0%       ~ (p=0.121 n=6)
geomean                                  1.613µ         1.614µ        +0.05%

                                 │ baseline-bench.txt │        benchmark-results.txt         │
                                 │        B/op        │     B/op      vs base                │
JQTransform_Simple-4                   1.273Ki ± 0%     1.273Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4       1.773Ki ± 0%     1.773Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4              2.625Ki ± 0%     2.625Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                  16.22Ki ± 0%     16.22Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4               1.984Ki ± 0%     1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²                 +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │     allocs/op      │ allocs/op   vs base                │
JQTransform_Simple-4                     10.00 ± 0%     10.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4         15.00 ± 0%     15.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4                30.00 ± 0%     30.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                    324.0 ± 0%     324.0 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4                 17.00 ± 0%     17.00 ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │       sec/op       │    sec/op     vs base              │
SchemaValidation_Simple-4                   1.105µ ±  2%   1.109µ ± 10%       ~ (p=0.485 n=6)
SchemaValidation_AllFields-4                1.666µ ± 25%   1.657µ ±  4%       ~ (p=0.420 n=6)
SchemaValidation_FormatValidation-4         1.587µ ±  1%   1.585µ ±  2%       ~ (p=0.513 n=6)
SchemaValidation_ManySchemas-4              1.836µ ±  1%   1.832µ ±  4%       ~ (p=0.818 n=6)
geomean                                     1.521µ         1.520µ        -0.12%

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │        B/op        │    B/op     vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │     allocs/op      │ allocs/op   vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
                                   │ baseline-bench.txt │       benchmark-results.txt        │
                                   │       sec/op       │    sec/op     vs base              │
EventStoreAppend_InMemory-4                1.218µ ± 14%   1.201µ ± 20%       ~ (p=0.589 n=6)
EventStoreAppend_SQLite-4                  1.259m ±  5%   1.292m ±  7%       ~ (p=0.699 n=6)
GetTimeline_InMemory/events-10-4           13.48µ ±  3%   13.77µ ±  3%       ~ (p=0.065 n=6)
GetTimeline_InMemory/events-50-4           76.33µ ±  1%   77.61µ ±  1%  +1.67% (p=0.002 n=6)
GetTimeline_InMemory/events-100-4          121.9µ ± 27%   123.2µ ± 24%       ~ (p=0.394 n=6)
GetTimeline_InMemory/events-500-4          624.7µ ±  0%   632.6µ ±  1%  +1.26% (p=0.002 n=6)
GetTimeline_InMemory/events-1000-4         1.271m ±  1%   1.294m ±  1%  +1.81% (p=0.002 n=6)
GetTimeline_SQLite/events-10-4             106.9µ ±  1%   106.7µ ±  0%       ~ (p=0.699 n=6)
GetTimeline_SQLite/events-50-4             247.6µ ±  1%   248.2µ ±  1%       ~ (p=1.000 n=6)
GetTimeline_SQLite/events-100-4            416.4µ ±  1%   420.0µ ±  0%  +0.86% (p=0.002 n=6)
GetTimeline_SQLite/events-500-4            1.769m ±  1%   1.790m ±  1%  +1.18% (p=0.002 n=6)
GetTimeline_SQLite/events-1000-4           3.460m ±  1%   3.473m ±  1%       ~ (p=0.180 n=6)
geomean                                    216.0µ         218.1µ        +0.97%

                                   │ baseline-bench.txt │        benchmark-results.txt         │
                                   │        B/op        │     B/op      vs base                │
EventStoreAppend_InMemory-4                  812.0 ± 5%     809.5 ± 8%       ~ (p=0.699 n=6)
EventStoreAppend_SQLite-4                  1.985Ki ± 2%   1.982Ki ± 2%       ~ (p=0.658 n=6)
GetTimeline_InMemory/events-10-4           7.953Ki ± 0%   7.953Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4           46.62Ki ± 0%   46.62Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4          94.48Ki ± 0%   94.48Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4          472.8Ki ± 0%   472.8Ki ± 0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-1000-4         944.3Ki ± 0%   944.3Ki ± 0%       ~ (p=0.675 n=6)
GetTimeline_SQLite/events-10-4             16.74Ki ± 0%   16.74Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4             87.14Ki ± 0%   87.14Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4            175.4Ki ± 0%   175.4Ki ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4            846.1Ki ± 0%   846.1Ki ± 0%       ~ (p=0.318 n=6)
GetTimeline_SQLite/events-1000-4           1.639Mi ± 0%   1.639Mi ± 0%       ~ (p=0.989 n=6)
geomean                                    67.50Ki        67.48Ki       -0.04%
¹ all samples are equal

                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │     allocs/op      │  allocs/op   vs base                │
EventStoreAppend_InMemory-4                  7.000 ± 0%    7.000 ± 0%       ~ (p=1.000 n=6) ¹
EventStoreAppend_SQLite-4                    53.00 ± 0%    53.00 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-10-4             125.0 ± 0%    125.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4             653.0 ± 0%    653.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4           1.306k ± 0%   1.306k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4           6.514k ± 0%   6.514k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-1000-4          13.02k ± 0%   13.02k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-10-4               382.0 ± 0%    382.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4              1.852k ± 0%   1.852k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4             3.681k ± 0%   3.681k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4             18.54k ± 0%   18.54k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-1000-4            37.29k ± 0%   37.29k ± 0%       ~ (p=1.000 n=6) ¹
geomean                                     1.162k        1.162k       +0.00%
¹ all samples are equal

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

Add preparePluginStagingDir/commitPluginStagingDir helpers that extract/copy
into a sibling .installing directory and atomically rename it into place.

This prevents stale executables after upgrades because:
1. The old directory is removed before the new one is moved in
2. ensurePluginBinary can no longer find the old binary in the staging dir

Also add verifyInstalledVersion to confirm plugin.json version matches
the manifest after a registry install.

Tests added:
- TestInstallPluginFromManifest_UpgradeReplacesStaleBinary
- TestInstallFromURL_UpgradeReplacesStaleBinary
- TestInstallFromLocal_UpgradeReplacesStaleBinary
- TestInstallPluginFromManifest_StagingCleanedUpOnFailure

Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/37d41012-d306-495a-af1f-9b948158f6e6

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 13, 2026 06:11
Copilot AI changed the title [WIP] Fix wfctl plugin install to update stale executable fix: atomic plugin dir replacement to prevent stale executables on upgrade May 13, 2026
Copilot AI requested a review from intel352 May 13, 2026 06:13
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 34.42623% with 40 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/wfctl/plugin_install.go 34.42% 18 Missing and 22 partials ⚠️

📢 Thoughts on this report? Let us know!

@intel352 intel352 marked this pull request as ready for review May 13, 2026 22:14
Copilot AI review requested due to automatic review settings May 13, 2026 22:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates wfctl plugin install to stage plugin installs before replacing the final plugin directory, aiming to prevent stale binaries during upgrades.

Changes:

  • Adds staging directory preparation, commit, and version verification helpers.
  • Updates registry, URL, and local install paths to install into staging first.
  • Adds regression tests for upgrade replacement and staging cleanup behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
cmd/wfctl/plugin_install.go Implements staged plugin install flow across install sources.
cmd/wfctl/plugin_install_test.go Adds upgrade and failure-path tests for staged installs.
Comments suppressed due to low confidence (2)

cmd/wfctl/plugin_install.go:321

  • When the verify hook is enabled, this tarball is written under the staging directory but the deferred remove still references the staging path. After commitPluginStagingDir renames staging to the final directory, the deferred removal becomes a no-op and the .tar.gz is left in the installed plugin directory.
		tarballPath := filepath.Join(stagingDir, pluginName+".tar.gz")
		if writeErr := os.WriteFile(tarballPath, data, 0600); writeErr != nil {
			return fmt.Errorf("write tarball for verify hook: %w", writeErr)
		}
		defer os.Remove(tarballPath) //nolint:errcheck

cmd/wfctl/plugin_install_test.go:668

  • installFromLocal also updates .wfctl-lock.yaml in the process current directory, but this test only isolates the plugin install directory. It should run from a temporary cwd or otherwise isolate the lockfile so the test does not mutate the package checkout or depend on an existing lockfile.
	if err := installFromLocal(makeLocalSrc("1.0.0", oldBinary), pluginDir); err != nil {
		t.Fatalf("first install: %v", err)
	}
	binaryPath := filepath.Join(pluginDir, pluginName, pluginName)
	gotOld, err := os.ReadFile(binaryPath)
	if err != nil {
		t.Fatalf("read v1 binary: %v", err)
	}
	if !bytes.Equal(gotOld, oldBinary) {
		t.Fatalf("after first install, binary = %q, want %q", gotOld, oldBinary)
	}

	// Upgrade.
	if err := installFromLocal(makeLocalSrc("2.0.0", newBinary), pluginDir); err != nil {

Comment thread cmd/wfctl/plugin_install.go Outdated
Comment on lines +1426 to +1436
// commitPluginStagingDir atomically replaces destDir with stagingDir. It
// removes the previous destDir content and renames the staging directory into
// its place. On success stagingDir no longer exists on disk; the deferred
// cleanup returned by preparePluginStagingDir becomes a harmless no-op.
func commitPluginStagingDir(stagingDir, destDir string) error {
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("remove existing plugin dir %s: %w", destDir, err)
}
if err := os.Rename(stagingDir, destDir); err != nil {
return fmt.Errorf("install plugin dir %s: %w", destDir, err)
}
Comment on lines +341 to 344
pluginJSONPath := filepath.Join(stagingDir, "plugin.json")
if writeErr := writeInstalledManifest(pluginJSONPath, manifest); writeErr != nil {
fmt.Fprintf(os.Stderr, "warning: could not write plugin.json: %v\n", writeErr)
}
Comment thread cmd/wfctl/plugin_install_test.go Outdated

// Attempt upgrade with a failing URL.
badManifest := makeTestManifest(pluginName, srv.URL+"/nonexistent.tar.gz", strings.Repeat("0", 64))
_ = installPluginFromManifest(pluginDir, pluginName, badManifest, nil, false) // expected to fail
Comment on lines +603 to +617
if err := installFromURL(srv.URL+"/p.tar.gz", pluginDir, sha256sum(oldTar), false); err != nil {
t.Fatalf("first install: %v", err)
}
binaryPath := filepath.Join(pluginDir, pluginName, pluginName)
gotOld, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatalf("read v1 binary: %v", err)
}
if !bytes.Equal(gotOld, oldBinary) {
t.Fatalf("after first install, binary = %q, want %q", gotOld, oldBinary)
}

// Upgrade.
serveData = newTar
if err := installFromURL(srv.URL+"/p.tar.gz", pluginDir, sha256sum(newTar), false); err != nil {
@intel352
Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

And improve test coverage #645 (comment)

…lity

- commitPluginStagingDir: rename existing dir to .uninstalling before moving
  staging in place; restore on failure, remove on success. This prevents the
  old install from being destroyed if the final rename fails.

- installPluginFromManifest: make writeInstalledManifest failure a hard error
  instead of a warning so registry-only metadata (capabilities, CLI commands)
  cannot be silently dropped and an archive-supplied plugin.json cannot bypass
  verifyInstalledVersion.

- TestInstallPluginFromManifest_StagingCleanedUpOnFailure: assert the failing
  install actually returns an error (was discarding it with _).

- TestInstallFromURL_UpgradeReplacesStaleBinary,
  TestInstallFromLocal_UpgradeReplacesStaleBinary,
  TestInstallFromURL_WithExpectedSHA256_Correct,
  TestInstallFromURL_SkipChecksum_NonGitHub: add t.Chdir(t.TempDir()) so
  updateLockfileWithChecksum writes .wfctl-lock.yaml to a temp dir rather than
  the package checkout, preventing lockfile pollution and test interdependence.

Agent-Logs-Url: https://github.com/GoCodeAlone/workflow/sessions/6bc57919-fc52-426b-94ba-1545ffcd1537

Co-authored-by: intel352 <77607+intel352@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 13, 2026

@copilot apply changes based on the comments in this thread

And improve test coverage https://gith...

Applied all four review fixes in commit 09cee15:

  1. commitPluginStagingDir atomicity — now renames the existing install to .uninstalling before renaming staging in, then removes the trash on success. If the staging rename fails the previous install is restored, so no window where the plugin directory is gone.

  2. writeInstalledManifest hard error — changed from a fmt.Fprintf warning to a return fmt.Errorf(...) so an archive-supplied plugin.json can never pass verifyInstalledVersion on behalf of the registry manifest.

  3. Failure-path test assertionTestInstallPluginFromManifest_StagingCleanedUpOnFailure now explicitly checks err == nil and fails the test if the bad-URL install unexpectedly succeeds.

  4. Lockfile pollution in tests — added t.Chdir(t.TempDir()) at the top of TestInstallFromURL_UpgradeReplacesStaleBinary, TestInstallFromLocal_UpgradeReplacesStaleBinary, TestInstallFromURL_WithExpectedSHA256_Correct, and TestInstallFromURL_SkipChecksum_NonGitHub (the two new tests plus two pre-existing ones with the same issue).

@intel352 intel352 merged commit 30f70e0 into main May 13, 2026
20 of 21 checks passed
@intel352 intel352 deleted the copilot/fix-stale-executable-issue branch May 13, 2026 23:28
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.

wfctl plugin install reports upgraded plugin but leaves stale executable

3 participants