G1.5: PreDeploy / PostDeploy convention hooks
Summary
Adds two new pipeline steps that look for PreDeploy.sh and PostDeploy.sh in the working directory (typically extracted by G1.4 from the package) and run them via the same script engine + variable preamble as the operator's main script.
Stacks on #369 (G1.4 ExtractPackageStep). Together they make a typical operator workflow end-to-end: package arrives → extract → rewriter pipeline → PreDeploy hook → main script → PostDeploy hook → cleanup.
Pipeline shape after this PR
ResolveWorkingDirectory → LoadVariablesFromFiles →
ExtractPackage (G1.4) →
SubstituteInFiles → ConfigurationTransforms → JsonConfigVariables (G1.1-3) →
**PreDeploy (G1.5)** →
WriteBootstrappedBashScript → ExecuteScriptWithEngine [operator's main] →
**PostDeploy (G1.5)** →
BuildRunScriptCommandResult → CleanupTemporaryFiles
Behaviour
| Concern | Behaviour |
|---|---|
| Script absent from working dir | Step is a no-op (most packages won't ship conventions) |
| Variable scope | Same export-VAR preamble as the main script — identical scope |
| Non-zero exit | Throws → halts deploy. PreDeploy fails before main runs; PostDeploy fails after main runs but before cleanup |
| Output variables emitted by the hook | Merge into context.Variables — PreDeploy can compute values for main; PostDeploy can harvest values main set |
| Temp file tracking | Bootstrapped temp added to context.TemporaryFiles so cleanup gets it |
Wire-contract surface
| Const | Value | Purpose |
|---|---|---|
ConventionScriptNames.PreDeploy |
"PreDeploy" |
Operators name files in their packages this exact string |
ConventionScriptNames.PostDeploy |
"PostDeploy" |
Same |
Pinned by tests (Rule 8 drift detectors).
What's deferred
DeployFailed.sh (Octopus has it as a fourth convention — runs only on execution failure) is intentionally not in this PR. Modelling it cleanly needs the pipeline context to expose a "execution failed" flag for an IAlwaysRunExecutionStep variant to read. Will land separately if operator demand surfaces.
Test plan
-
Squid.Calamari.Tests: 314/314 (was 300; +14) -
Squid.UnitTests: 5625/5625 (cross-project — unchanged) -
Solution build: 0 errors -
Wire literals ( PreDeploy,PostDeploy) pinned (Rule 8) -
Gating: present-runs / absent-skips / sibling-convention-doesn't-match / WorkingDir-null-doesn't-crash -
Bootstrapping: temp file fed to engine (not original source) + preamble correctly composed + tracked for cleanup -
Failure: non-zero exit code throws with operator-actionable message -
Output variables flow back into the shared variable set -
Defensive: empty convention name + null engine + null WorkingDir all throw cleanly -
Staging: real .nupkg containing PreDeploy.sh+PostDeploy.shend-to-end
Non-breaking guarantee
| Surface | Status |
|---|---|
| Existing wire literals | Unchanged |
Standalone-script deploys (no PreDeploy.sh / PostDeploy.sh) |
Zero impact — both steps' IsEnabled returns false |
| Operator's main script execution | Unchanged — same ExecuteScriptWithEngineStep between the two new hook positions |
| Frontend | Untouched — convention scripts are file-system-driven, no UI wire literals needed |