From ff81bbf8dc9ead904b4e51928f3d6606efeba245 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 24 Jul 2026 10:35:40 -0300 Subject: [PATCH] fix(ci): harden release and rollout checks --- .github/workflows/release-tagging.yaml | 87 +++++----------- scripts/release-changes.test.ts | 133 ++++++++++++++++++++++++ scripts/release-changes.ts | 134 +++++++++++++++++++++++++ tests/multi-pod/docker-compose.yml | 2 + 4 files changed, 294 insertions(+), 62 deletions(-) create mode 100644 scripts/release-changes.test.ts create mode 100644 scripts/release-changes.ts diff --git a/.github/workflows/release-tagging.yaml b/.github/workflows/release-tagging.yaml index 08ca377a4a..051f47f69c 100644 --- a/.github/workflows/release-tagging.yaml +++ b/.github/workflows/release-tagging.yaml @@ -9,7 +9,9 @@ on: - "apps/web/**" - "package.json" - "scripts/build-studio.ts" + - "scripts/release-changes.ts" - "packages/**" + - ".github/workflows/release-tagging.yaml" # Baked into the nginx image at build time (apps/web/Dockerfile), so a change # here must cut a release to rebuild that image — it is not a ConfigMap. - "deploy/helm/studio/files/**" @@ -47,11 +49,19 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 with: + fetch-depth: 0 + ref: main token: ${{ steps.app-token.outputs.token }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + - name: Find the merged pull request id: find_pr env: + EVENT_SHA: ${{ github.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail @@ -59,7 +69,7 @@ jobs: PR_DATA=$(gh api \ -H "Accept: application/vnd.github+json" \ "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" \ - --jq 'map(select(.merged_at != null)) | sort_by(.merged_at) | reverse | .[0] // {}') + --jq "map(select(.merged_at != null and .merge_commit_sha == \"${EVENT_SHA}\")) | .[0] // {}") PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number // empty') PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // empty') @@ -106,66 +116,27 @@ jobs: id: changed_workspaces if: steps.find_pr.outputs.pr_number != '' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MERGE_SHA: ${{ github.sha }} PR_NUMBER: ${{ steps.find_pr.outputs.pr_number }} run: | set -euo pipefail - FILES=$(gh api --paginate \ - "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" \ - --jq '.[].filename') - - echo "Changed files:" - echo "$FILES" - - PACKAGE_MANIFESTS=$(FILES="$FILES" node <<'NODE' - const fs = require("fs"); - const files = (process.env.FILES ?? "").split("\n").filter(Boolean); - const manifests = new Set(); - - for (const file of files) { - if ( - file === "package.json" || - file === "scripts/build-studio.ts" - ) { - manifests.add("apps/api/package.json"); - continue; - } - - if ( - file.startsWith("apps/api/") || - file.startsWith("apps/web/") - ) { - manifests.add("apps/api/package.json"); - continue; - } - - // The nginx conf is baked into the studio-nginx image, tagged with - // apps/api's version — so a change here must bump that version to - // force a rebuild (there is no ConfigMap to reload). - if (file.startsWith("deploy/helm/studio/files/")) { - manifests.add("apps/api/package.json"); - continue; - } - - const match = file.match(/^packages\/([^/]+)\//); - if (match) { - manifests.add(`packages/${match[1]}/package.json`); - } - } + if ! git merge-base --is-ancestor "$MERGE_SHA" origin/main; then + echo "::error::Merge commit $MERGE_SHA is not on origin/main" + exit 1 + fi - const existing = [...manifests] - .filter((manifest) => fs.existsSync(manifest)) - .filter((manifest) => { - const pkg = JSON.parse(fs.readFileSync(manifest, "utf8")); - return typeof pkg.version === "string"; - }) - .sort(); + DIFF_BASE=$(git rev-parse "${MERGE_SHA}^1") + CHANGED_FILES_PATH="$RUNNER_TEMP/release-tagging-${PR_NUMBER}.paths" + git diff --no-renames --name-only -z \ + "$DIFF_BASE" "$MERGE_SHA" -- > "$CHANGED_FILES_PATH" - console.log(JSON.stringify(existing)); - NODE - ) + RELEASE_CHANGES=$(bun run scripts/release-changes.ts "$CHANGED_FILES_PATH") + FILE_COUNT=$(jq -r '.fileCount' <<< "$RELEASE_CHANGES") + PACKAGE_MANIFESTS=$(jq -c '.packageManifests' <<< "$RELEASE_CHANGES") + SCOPE=$(jq -r '.deployScope' <<< "$RELEASE_CHANGES") + echo "Changed files: $FILE_COUNT" echo "Workspace manifests to bump: $PACKAGE_MANIFESTS" echo "package_manifests=$PACKAGE_MANIFESTS" >> "$GITHUB_OUTPUT" @@ -173,14 +144,6 @@ jobs: # bump-studio-image): apps/web-only changes roll only the web pods; # apps/api-only changes roll api+worker; mixed or no app changes roll # both (safe default). - API_FILES=$(echo "$FILES" | grep '^apps/api/' || true) - WEB_FILES=$(echo "$FILES" | grep '^apps/web/' || true) - SCOPE=both - if [ -n "$API_FILES" ] && [ -z "$WEB_FILES" ]; then - SCOPE=server - elif [ -n "$WEB_FILES" ] && [ -z "$API_FILES" ]; then - SCOPE=web - fi echo "Deploy scope: $SCOPE" echo "deploy_scope=$SCOPE" >> "$GITHUB_OUTPUT" diff --git a/scripts/release-changes.test.ts b/scripts/release-changes.test.ts new file mode 100644 index 0000000000..2b1a8bef6a --- /dev/null +++ b/scripts/release-changes.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { + classifyReleaseChanges, + isVersionedManifest, + parseChangedFiles, + releaseManifestCandidates, +} from "./release-changes"; + +const API_MANIFEST = "apps/api/package.json"; + +describe("release change classification", () => { + test.each([ + { + files: ["apps/api/src/index.ts"], + manifests: [API_MANIFEST], + scope: "server", + }, + { + files: ["apps/web/src/main.tsx"], + manifests: [API_MANIFEST], + scope: "web", + }, + { + files: ["apps/api/src/index.ts", "apps/web/src/main.tsx"], + manifests: [API_MANIFEST], + scope: "both", + }, + { + files: ["packages/runtime/src/index.ts"], + manifests: ["packages/runtime/package.json"], + scope: "both", + }, + ] as const)( + "classifies $scope deploy changes", + ({ files, manifests, scope }) => { + expect(classifyReleaseChanges(files, new Set(manifests))).toMatchObject({ + deployScope: scope, + packageManifests: manifests, + }); + }, + ); + + test("maps release inputs to sorted, deduplicated manifests", () => { + const files = [ + "packages/ui/src/button.tsx", + "apps/web/src/main.tsx", + "apps/api/src/index.ts", + "package.json", + "scripts/build-studio.ts", + "deploy/helm/studio/files/nginx.conf", + "packages/runtime/src/index.ts", + "packages/ui/src/input.tsx", + ]; + + expect(releaseManifestCandidates(files)).toEqual([ + API_MANIFEST, + "packages/runtime/package.json", + "packages/ui/package.json", + ]); + }); + + test("filters deleted and unversioned workspaces", () => { + const files = [ + "packages/deleted/src/index.ts", + "packages/private/src/index.ts", + "packages/versioned/src/index.ts", + ]; + + expect( + classifyReleaseChanges( + files, + new Set(["packages/versioned/package.json"]), + ).packageManifests, + ).toEqual(["packages/versioned/package.json"]); + }); + + test("recognizes only string package versions", () => { + expect(isVersionedManifest({ version: "0.0.0" })).toBe(true); + expect(isVersionedManifest({ version: 1 })).toBe(false); + expect(isVersionedManifest({ private: true })).toBe(false); + expect(isVersionedManifest(null)).toBe(false); + }); + + test("handles a #5120-sized file list", () => { + const relevantFiles = [ + "apps/api/src/index.ts", + "apps/web/src/main.tsx", + "packages/bindings/src/index.ts", + "packages/create-deco/index.ts", + "packages/e2e/tests/smoke.spec.ts", + "packages/harness/src/index.ts", + "packages/mcp-utils/src/index.ts", + "packages/runtime/src/index.ts", + "packages/sandbox/src/index.ts", + "packages/shared/src/index.ts", + "packages/tunnel/src/index.ts", + "packages/typegen/src/index.ts", + "packages/ui/src/index.ts", + "packages/mesh-sdk/src/index.ts", + "packages/std/src/index.ts", + "apps/mesh/src/index.ts", + ]; + const fillerFiles = Array.from( + { length: 2_823 - relevantFiles.length }, + (_, index) => + `docs/generated/${index.toString().padStart(4, "0")}-${"x".repeat(40)}.md`, + ); + const serialized = `${[...relevantFiles, ...fillerFiles].join("\0")}\0`; + const files = parseChangedFiles(serialized); + const expectedManifests = [ + API_MANIFEST, + "packages/bindings/package.json", + "packages/create-deco/package.json", + "packages/e2e/package.json", + "packages/harness/package.json", + "packages/mcp-utils/package.json", + "packages/runtime/package.json", + "packages/sandbox/package.json", + "packages/shared/package.json", + "packages/tunnel/package.json", + "packages/typegen/package.json", + "packages/ui/package.json", + ]; + + expect(Buffer.byteLength(serialized)).toBeGreaterThan(128 * 1024); + expect(files).toHaveLength(2_823); + expect(classifyReleaseChanges(files, new Set(expectedManifests))).toEqual({ + deployScope: "both", + fileCount: 2_823, + packageManifests: expectedManifests, + }); + }); +}); diff --git a/scripts/release-changes.ts b/scripts/release-changes.ts new file mode 100644 index 0000000000..90b6d87e8d --- /dev/null +++ b/scripts/release-changes.ts @@ -0,0 +1,134 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +const API_MANIFEST = "apps/api/package.json"; + +export type DeployScope = "both" | "server" | "web"; + +export interface ReleaseChanges { + deployScope: DeployScope; + fileCount: number; + packageManifests: string[]; +} + +export function parseChangedFiles(contents: string): string[] { + const separator = contents.includes("\0") ? "\0" : "\n"; + return contents + .split(separator) + .map((file) => (separator === "\n" ? file.replace(/\r$/, "") : file)) + .filter(Boolean); +} + +export function releaseManifestCandidates(files: readonly string[]): string[] { + const manifests = new Set(); + + for (const file of files) { + if (file === "package.json" || file === "scripts/build-studio.ts") { + manifests.add(API_MANIFEST); + continue; + } + + if (file.startsWith("apps/api/") || file.startsWith("apps/web/")) { + manifests.add(API_MANIFEST); + continue; + } + + // The nginx config is baked into the web image tagged with the API version. + if (file.startsWith("deploy/helm/studio/files/")) { + manifests.add(API_MANIFEST); + continue; + } + + const packageMatch = /^packages\/([^/]+)\//.exec(file); + const packageDirectory = packageMatch?.[1]; + if (packageDirectory) { + manifests.add(`packages/${packageDirectory}/package.json`); + } + } + + return [...manifests].sort(); +} + +export function isVersionedManifest(manifest: unknown): boolean { + return ( + typeof manifest === "object" && + manifest !== null && + "version" in manifest && + typeof manifest.version === "string" + ); +} + +export function classifyReleaseChanges( + files: readonly string[], + versionedManifests: ReadonlySet, +): ReleaseChanges { + const hasApiChanges = files.some((file) => file.startsWith("apps/api/")); + const hasWebChanges = files.some((file) => file.startsWith("apps/web/")); + const deployScope = + hasApiChanges && !hasWebChanges + ? "server" + : hasWebChanges && !hasApiChanges + ? "web" + : "both"; + + return { + deployScope, + fileCount: files.length, + packageManifests: releaseManifestCandidates(files).filter((manifest) => + versionedManifests.has(manifest), + ), + }; +} + +async function findVersionedManifests( + candidates: readonly string[], + repositoryRoot: string, +): Promise> { + const versionedManifests = new Set(); + + for (const manifestPath of candidates) { + let source: string; + try { + source = await readFile(join(repositoryRoot, manifestPath), "utf8"); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + continue; + } + throw error; + } + + if (isVersionedManifest(JSON.parse(source))) { + versionedManifests.add(manifestPath); + } + } + + return versionedManifests; +} + +async function main(): Promise { + const changedFilesPath = Bun.argv[2]; + if (!changedFilesPath) { + throw new Error("Usage: bun scripts/release-changes.ts "); + } + + const files = parseChangedFiles(await readFile(changedFilesPath, "utf8")); + const repositoryRoot = resolve(import.meta.dir, ".."); + const candidates = releaseManifestCandidates(files); + const versionedManifests = await findVersionedManifests( + candidates, + repositoryRoot, + ); + + process.stdout.write( + JSON.stringify(classifyReleaseChanges(files, versionedManifests)), + ); +} + +if (import.meta.main) { + await main(); +} diff --git a/tests/multi-pod/docker-compose.yml b/tests/multi-pod/docker-compose.yml index befbacdb42..f702489919 100644 --- a/tests/multi-pod/docker-compose.yml +++ b/tests/multi-pod/docker-compose.yml @@ -108,6 +108,8 @@ services: DECOCMS_LOCAL_MODE: "true" ENCRYPTION_KEY: test-encryption-key-32chars-long! DATA_DIR: /app/data + # Bound a silent recovered run inside rollout-churn's terminal poll. + RUN_IDLE_TIMEOUT_MS: "120000" # External S3 → ensureServices skips embedded MinIO (survives restarts). S3_ENDPOINT: http://minio:9000 S3_BUCKET: studio-dev