diff --git a/e2e/scenarios/31-native-deployments.yaml b/e2e/scenarios/31-native-deployments.yaml index 313d560..9d7cf69 100644 --- a/e2e/scenarios/31-native-deployments.yaml +++ b/e2e/scenarios/31-native-deployments.yaml @@ -52,13 +52,22 @@ steps: # A dry-run run skips its deploys, so it must not create a real # Deployment: every lifecycle step is also gated on a non-dry-run run. - " if: ${{ github.server_url == 'https://github.com' && github.event.inputs.dry_run != 'true' }}" - - " deployment_id=$(gh api repos/${{ github.repository }}/deployments \\" - - " --field auto_inactive=false \\" + # The create body is assembled as JSON and piped in via --input so + # required_contexts is a real, explicit empty array. An empty array + # means "no required contexts"; sending the literal string "[]" + # (via --field/--raw-field) made GitHub reject the create with 422. + - ' deployment_id=$(printf ''{"ref":"%s","environment":"%s","auto_merge":false,"required_contexts":[],"auto_inactive":false}'' "${{ github.sha }}" "$ENV_NAME" \' + - " | gh api repos/${{ github.repository }}/deployments \\" + - " --input - \\" - " - name: Set deployment in_progress" - " --field state=in_progress" - " - name: Set deployment status" - " if: ${{ github.server_url == 'https://github.com' && github.event.inputs.dry_run != 'true' && always() }}" - " production) environment_url='https://app.example.com' ;;" + not_contains: + # The string form GitHub rejected with HTTP 422 must never reappear. + - "--raw-field required_contexts='[]'" + - "required_contexts='[]'" - name: "Regenerate and confirm no drift" action: verify diff --git a/e2e/scenarios/57-component-partial-inherit.yaml b/e2e/scenarios/57-component-partial-inherit.yaml index da22639..67b5503 100644 --- a/e2e/scenarios/57-component-partial-inherit.yaml +++ b/e2e/scenarios/57-component-partial-inherit.yaml @@ -11,8 +11,8 @@ description: | enabled defaulting back to false: api's orchestrate workflow would have emitted no deployment reporting at all. Under deep-merge api keeps the inherited enabled: true and layers its keep_prior_active on top, so its finalize job - reports the deployment with auto_inactive=false, while web inherits the plain - enabled: true and reports with the default auto_inactive=true. The scenario + reports the deployment with auto_inactive false, while web inherits the plain + enabled: true and reports with the default auto_inactive true. The scenario generates the per-component set, proves the roundtrip is drift-free, and asserts each component's orchestrate workflow carries the correctly merged deployment reporting rather than the sibling's. @@ -70,21 +70,23 @@ steps: expect_exit: 0 # api set only keep_prior_active; it inherits the top-level enabled: true, so # its orchestrate workflow still creates a deployment and, because - # keep_prior_active merged in, reports it with auto_inactive=false. web + # keep_prior_active merged in, reports it with auto_inactive false. web # inherits the bare enabled: true and reports with the default - # auto_inactive=true. The not_contains cross-checks that neither component - # picked up the other's auto_inactive value. + # auto_inactive true. auto_inactive rides in the create-deployment JSON body + # ("auto_inactive":), so the assertions match that form. The + # not_contains cross-checks that neither component picked up the other's + # auto_inactive value. expect: workflow_files: - path: ".github/workflows/orchestrate-api.yaml" contains: - "Create deployment" - - "auto_inactive=false" + - '"auto_inactive":false' not_contains: - - "auto_inactive=true" + - '"auto_inactive":true' - path: ".github/workflows/orchestrate-web.yaml" contains: - "Create deployment" - - "auto_inactive=true" + - '"auto_inactive":true' not_contains: - - "auto_inactive=false" + - '"auto_inactive":false' diff --git a/internal/generate/native_deployments.go b/internal/generate/native_deployments.go index 87f39ac..f2ca298 100644 --- a/internal/generate/native_deployments.go +++ b/internal/generate/native_deployments.go @@ -62,14 +62,18 @@ func writeNativeDeploymentSteps(sb *strings.Builder, cfg *config.TrunkConfig, en sb.WriteString(body + " GH_TOKEN: ${{ github.token }}\n") sb.WriteString(body + "run: |\n") fmt.Fprintf(sb, "%s ENV_NAME=\"%s\"\n", body, envExpr) - fmt.Fprintf(sb, "%s deployment_id=$(gh api repos/${{ github.repository }}/deployments \\\n", body) - sb.WriteString(body + " --method POST \\\n") - sb.WriteString(body + " --field ref=${{ github.sha }} \\\n") - sb.WriteString(body + " --field environment=\"$ENV_NAME\" \\\n") - sb.WriteString(body + " --field auto_merge=false \\\n") - sb.WriteString(body + " --raw-field required_contexts='[]' \\\n") - fmt.Fprintf(sb, "%s --field auto_inactive=%t \\\n", body, deploymentAutoInactive(cfg)) - sb.WriteString(body + " --jq '.id')\n") + // Assemble the create-deployment request body as JSON and pipe it in via + // --input so required_contexts is a real empty array. An explicit [] means + // the deployment is not gated on any status contexts; omitting the field + // would instead make GitHub apply the repository's default required + // contexts, a different and potentially blocking behavior. gh api --field + // and --raw-field always send scalar strings, so an empty JSON array cannot + // be expressed through them; the body is built here and read from stdin. + fmt.Fprintf(sb, "%s deployment_id=$(printf '{\"ref\":\"%%s\",\"environment\":\"%%s\",\"auto_merge\":false,\"required_contexts\":[],\"auto_inactive\":%t}' \"${{ github.sha }}\" \"$ENV_NAME\" \\\n", body, deploymentAutoInactive(cfg)) + sb.WriteString(body + " | gh api repos/${{ github.repository }}/deployments \\\n") + sb.WriteString(body + " --method POST \\\n") + sb.WriteString(body + " --input - \\\n") + sb.WriteString(body + " --jq '.id')\n") sb.WriteString(body + " echo \"deployment_id=${deployment_id}\" >> \"$GITHUB_OUTPUT\"\n") // Mark the deployment in_progress. diff --git a/internal/generate/native_deployments_test.go b/internal/generate/native_deployments_test.go index 52e4cb0..a6b4f6d 100644 --- a/internal/generate/native_deployments_test.go +++ b/internal/generate/native_deployments_test.go @@ -74,6 +74,29 @@ func TestNativeDeployments_Enabled(t *testing.T) { "terminal status must derive from the deploy job result") } +// TestNativeDeployments_RequiredContextsIsJSONArray proves the create-deployment +// step sends required_contexts as a real JSON array, not the literal string +// "[]". gh api --field/--raw-field always transmit scalar strings, so the prior +// `--raw-field required_contexts='[]'` made GitHub receive "[]" and reject the +// create with HTTP 422 (not an array or null). The explicit empty array must be +// preserved, not dropped: omitting the field would let GitHub apply the repo's +// default required contexts instead of "no required contexts". +func TestNativeDeployments_RequiredContextsIsJSONArray(t *testing.T) { + cfg, tmpDir := nativeDeploymentsConfig(t) + + out, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + assert.NotContains(t, out, "--raw-field required_contexts='[]'", + "required_contexts must not be sent via --raw-field (transmits the literal string \"[]\", which GitHub rejects with HTTP 422)") + assert.NotContains(t, out, "required_contexts='[]'", + "required_contexts must not be sent as a shell string in any form") + assert.Contains(t, out, `"required_contexts":[]`, + "required_contexts must be sent as a real, explicit empty JSON array") + assert.Contains(t, out, "--input -", + "the create-deployment body must be piped in as JSON via --input so the empty array survives") +} + // TestNativeDeployments_Disabled proves none of the Deployments API wiring is // emitted when the toggle is absent, keeping the OFF-state output unchanged. func TestNativeDeployments_Disabled(t *testing.T) {