From e06cad077ca6d389f2dae70f15ec7fe0dec0c96c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 24 Sep 2026 13:41:39 +0000 Subject: [PATCH 1/3] fix(release): fail closed when draft state lookup fails --- .github/workflows/release.yml | 3 ++- tests/ci-workflows/release-pipeline-contract.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8857697a6a..c0ff19943c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -689,7 +689,8 @@ jobs: # release is therefore created as a draft and becomes public here, once the # verified bundle is attached. The only edit permitted is this flip — the # notes still come from the validated notes file written at creation. - if [ "$(gh release view "$release_tag" --json isDraft --jq .isDraft)" = "true" ]; then + draft_state="$(gh release view "$release_tag" --json isDraft --jq .isDraft)" + if [ "$draft_state" = "true" ]; then gh release edit "$release_tag" --draft=false fi diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index d2a90449fa..48054d5586 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -167,6 +167,18 @@ describe("release pipeline contract", () => { .find(run => run.includes("gh release upload")) ?? ""; expect(attachRun).toContain("--draft=false"); expect(attachRun.indexOf("gh release upload")).toBeLessThan(attachRun.indexOf("--draft=false")); + + const failedDraftRead = Bun.spawnSync( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` + gh() { + if [ "$2" = "view" ]; then return 41; fi + return 0 + } + ${attachRun} + `], + { env: { ...process.env, RELEASE_VERSION: "2.65.0" } }, + ); + expect(failedDraftRead.exitCode).toBe(41); }); test("a partial publication has a recorded, explicit recovery path", () => { From 236aa05951a19b50e91125057daeb56d0b5887fd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:51:23 +0900 Subject: [PATCH 2/3] fix(release): fail on any draft state other than an explicit false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful draft-state query that answers neither true nor false — an empty body or an unexpected shape — used to skip the publish step and leave the release a silent draft. Only an explicit false may pass now; anything else fails the step. The contract test covers the unexpected value alongside the existing failing-query case. --- .github/workflows/release.yml | 11 ++++++++--- .../ci-workflows/release-pipeline-contract.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0ff19943c..8d76276829 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -690,9 +690,14 @@ jobs: # verified bundle is attached. The only edit permitted is this flip — the # notes still come from the validated notes file written at creation. draft_state="$(gh release view "$release_tag" --json isDraft --jq .isDraft)" - if [ "$draft_state" = "true" ]; then - gh release edit "$release_tag" --draft=false - fi + case "$draft_state" in + true) gh release edit "$release_tag" --draft=false ;; + false) ;; + # A successful query that answers neither true nor false — an empty body or an + # unexpected shape — must not leave the release a silent draft: only an explicit + # false may pass. + *) echo "unexpected draft state: $draft_state" >&2; exit 1 ;; + esac # One row per fact a release run can establish: the public GitHub release, the npm version read # back from the registry, and the npm dist-tag. A green run used to read the same whichever of diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 48054d5586..a81e176642 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -179,6 +179,19 @@ describe("release pipeline contract", () => { { env: { ...process.env, RELEASE_VERSION: "2.65.0" } }, ); expect(failedDraftRead.exitCode).toBe(41); + // Only an explicit "false" may pass: a successful query that answers anything else — + // an empty body or an unexpected shape — fails the step rather than leaving a silent draft. + const strangeDraftRead = Bun.spawnSync( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` + gh() { + if [ "$2" = "view" ]; then echo "null"; return 0; fi + return 0 + } + ${attachRun} + `], + { env: { ...process.env, RELEASE_VERSION: "2.65.0" } }, + ); + expect(strangeDraftRead.exitCode).toBe(1); }); test("a partial publication has a recorded, explicit recovery path", () => { From f1690aae69b1fd16a5ec27fbd351aaadd0e903e7 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 25 Sep 2026 03:28:16 +0000 Subject: [PATCH 3/3] test(release): cover fail-closed publication with portable isolated Bash regressions Keep the existing release workflow fix unchanged. Separate static contracts from shell execution, find native Git Bash on Windows, and refuse silently skipped shell coverage on CI. Mock only the expected gh commands in an empty working directory with no inherited credentials or executable search path. Assert success paths, command order, six malformed states, and upload/view/edit failure propagation. Add subprocess time and output bounds. Local preliminary validation: TypeScript syntax and 12 checks through a Node adapter invoking actual Bash passed; restoring the original bug or making the script always fail was detected. Native Bun cross-platform validation pending. --- .../release-pipeline-contract.test.ts | 130 ++++++++++++++---- 1 file changed, 105 insertions(+), 25 deletions(-) diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index a81e176642..2296c027de 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; type WorkflowStep = { @@ -40,6 +43,43 @@ function triggerPaths(workflowText: string, trigger: string, until: string): str .map(line => line.slice(3, -1)); } +/** Keep credentials and shell startup hooks out of mocked release subprocesses. */ +function releaseTestEnv(extra: Record = {}): Record { + const env: Record = { PATH: "", BASH_ENV: "", ENV: "" }; + for (const key of ["SystemRoot", "WINDIR", "TEMP", "TMP"]) { + if (process.env[key]) env[key] = process.env[key]!; + } + return { ...env, ...extra }; +} + +/** Prefer native Git Bash on Windows; never invoke the System32 WSL launcher. */ +function releaseTestBash(): string | null { + const candidates: string[] = []; + if (process.platform === "win32") { + const git = Bun.which("git"); + if (git) candidates.push(join(dirname(git), "..", "bin", "bash.exe"), + join(dirname(git), "..", "usr", "bin", "bash.exe")); + for (const root of [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]) { + if (root) candidates.push(join(root, "Git", "bin", "bash.exe")); + } + if (process.env.LOCALAPPDATA) candidates.push( + join(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe")); + } + const onPath = Bun.which("bash"); + if (onPath && !/[\\/](?:system32|sysnative)[\\/]bash(?:\.exe)?$/i.test(onPath)) { + candidates.push(onPath); + } + for (const candidate of new Set(candidates)) { + if (!existsSync(candidate)) continue; + try { + const probe = Bun.spawnSync([candidate, "--noprofile", "--norc", "-c", 'printf "%s" "$BASH_VERSION"'], + { env: releaseTestEnv(), timeout: 2_000, maxBuffer: 4096 }); + if (probe.exitCode === 0 && probe.stdout.toString().trim()) return candidate; + } catch { /* Try another native installation; no shell means an explicit local skip. */ } + } + return null; +} + /** * Contracts for the release pipeline itself. Each assertion encodes a defect that a green * workflow can still carry: a bash script silently reinterpreted by PowerShell on a Windows @@ -167,31 +207,71 @@ describe("release pipeline contract", () => { .find(run => run.includes("gh release upload")) ?? ""; expect(attachRun).toContain("--draft=false"); expect(attachRun.indexOf("gh release upload")).toBeLessThan(attachRun.indexOf("--draft=false")); + }); - const failedDraftRead = Bun.spawnSync( - ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` - gh() { - if [ "$2" = "view" ]; then return 41; fi - return 0 - } - ${attachRun} - `], - { env: { ...process.env, RELEASE_VERSION: "2.65.0" } }, - ); - expect(failedDraftRead.exitCode).toBe(41); - // Only an explicit "false" may pass: a successful query that answers anything else — - // an empty body or an unexpected shape — fails the step rather than leaving a silent draft. - const strangeDraftRead = Bun.spawnSync( - ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` - gh() { - if [ "$2" = "view" ]; then echo "null"; return 0; fi - return 0 - } - ${attachRun} - `], - { env: { ...process.env, RELEASE_VERSION: "2.65.0" } }, - ); - expect(strangeDraftRead.exitCode).toBe(1); + describe("draft publication shell behavior", () => { + const bash = releaseTestBash(); + const shellTest = test.skipIf(bash === null); + + test("CI and POSIX hosts must execute the Bash regressions", () => { + // A Windows workstation need not install Bash just to run static contracts. + // CI must never turn a missing/broken shell into a green skipped regression. + if (process.env.CI || process.platform !== "win32") expect(bash).not.toBeNull(); + }); + + /** Run the actual YAML block with an allowlisted gh mock and no executable search path. */ + function attach(env: Record = {}) { + const run = release.jobs?.["attach-release"]?.steps + ?.find(step => step.run?.includes("gh release upload"))?.run; + expect(run).toBeDefined(); + const cwd = mkdtempSync(join(tmpdir(), "ocx-release-contract-")); + try { + const result = Bun.spawnSync([bash!, "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", ` + gh() { + printf 'gh:%s\\n' "$*" >&2 + case "$1:$2" in + release:upload) return "$UPLOAD_STATUS" ;; + release:view) printf '%s\\n' "$DRAFT_STATE"; return "$VIEW_STATUS" ;; + release:edit) return "$EDIT_STATUS" ;; + *) return 97 ;; + esac + } + ${run} + printf 'attach-completed\\n' + `], { cwd, env: releaseTestEnv({ RELEASE_VERSION: "2.65.0", DRAFT_STATE: "true", + UPLOAD_STATUS: "0", VIEW_STATUS: "0", EDIT_STATUS: "0", ...env }), + timeout: 3_000, maxBuffer: 16_384 }); + const calls = result.stderr.toString().split(/\r?\n/).filter(line => line.startsWith("gh:")); + return { code: result.exitCode, calls, completed: result.stdout.toString().includes("attach-completed") }; + } finally { + removeTreeWithRetry(cwd); + } + } + + const upload = "gh:release upload v2.65.0 dist/release/* --clobber"; + const view = "gh:release view v2.65.0 --json isDraft --jq .isDraft"; + const edit = "gh:release edit v2.65.0 --draft=false"; + + shellTest("lookup failure propagates even when stdout says true", () => { + expect(attach({ VIEW_STATUS: "41" })).toEqual({ code: 41, calls: [upload, view], completed: false }); + }); + for (const value of ["", "null", "TRUE", " true", "{}", "true\nfalse"]) { + shellTest(`unexpected draft state ${JSON.stringify(value)} never publishes`, () => { + expect(attach({ DRAFT_STATE: value })).toEqual({ code: 1, calls: [upload, view], completed: false }); + }); + } + shellTest("a draft is published exactly once, after upload and lookup", () => { + expect(attach()).toEqual({ code: 0, calls: [upload, view, edit], completed: true }); + }); + shellTest("an explicitly public release is not edited again", () => { + expect(attach({ DRAFT_STATE: "false" })).toEqual({ code: 0, calls: [upload, view], completed: true }); + }); + shellTest("upload failure prevents lookup and publication", () => { + expect(attach({ UPLOAD_STATUS: "42" })).toEqual({ code: 42, calls: [upload], completed: false }); + }); + shellTest("publication failure remains a failed step", () => { + expect(attach({ EDIT_STATUS: "43" })).toEqual({ code: 43, calls: [upload, view, edit], completed: false }); + }); }); test("a partial publication has a recorded, explicit recovery path", () => {