Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 25 additions & 62 deletions .github/workflows/release-tagging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down Expand Up @@ -47,19 +49,27 @@ 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

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')
Expand Down Expand Up @@ -106,81 +116,34 @@ 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"

# Deploy scope drives which CD tier rolls (see deco-apps-cd
# 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"

Expand Down
133 changes: 133 additions & 0 deletions scripts/release-changes.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading