diff --git a/apps/dev/src/services/__tests__/build-worker-release-script.test.ts b/apps/dev/src/services/__tests__/build-worker-release-script.test.ts index ea7c78a53f..6b4e2950d0 100644 --- a/apps/dev/src/services/__tests__/build-worker-release-script.test.ts +++ b/apps/dev/src/services/__tests__/build-worker-release-script.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createHash, randomBytes } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -90,6 +91,7 @@ exit 44 BASH_ENV: '/dev/null', PATH: `${fakeBinDir}:${process.env.PATH ?? ''}`, PNPM_LOG: pnpmLogPath, + GITHUB_SHA: '0123456789abcdef0123456789abcdef01234567', SENTRY_AUTH_TOKEN: 'task-sentry-token', SENTRY_ORG: '', SENTRY_PROJECT: '', @@ -100,6 +102,15 @@ exit 44 expect( fs.existsSync(path.join(outputDir, 'worker-vlocal-dev.tar.gz')), ).toBe(true); + const digest = createHash('sha256') + .update(fs.readFileSync(path.join(outputDir, 'worker-vlocal-dev.tar.gz'))) + .digest('hex'); + expect( + fs.readFileSync( + path.join(outputDir, 'worker-vlocal-dev.tar.gz.sha256'), + 'utf8', + ), + ).toBe(`${digest} worker-vlocal-dev.tar.gz\n`); expect(fs.readFileSync(pnpmLogPath, 'utf8')).toBe( '--filter @roomote/worker build\n', ); @@ -114,5 +125,95 @@ exit 44 ]); expect(stdout).toBe(content); } + for (const [file, content] of [ + ['NODE_PTY_VERSION', '1.0.0\n'], + ['PM2_VERSION', '7.0.1\n'], + ['VERSION', 'local-dev\n'], + ['COMMIT', '0123456789abcdef0123456789abcdef01234567\n'], + ]) { + const { stdout } = await execa( + 'tar', + [ + '-xOzf', + path.join(outputDir, 'worker-vlocal-dev.tar.gz'), + `worker-vlocal-dev/${file}`, + ], + { stripFinalNewline: false }, + ); + expect(stdout).toBe(content); + } + }); + + it('preserves the last release and cleans unpublished files when native tar fails', async () => { + const { stdout: nativeTar } = await execa('bash', ['-c', 'command -v tar']); + for (const command of ['tar', 'gtar']) { + fs.writeFileSync( + path.join(fakeBinDir, command), + `#!/bin/bash\nulimit -f 128\nexec ${JSON.stringify(nativeTar.trim())} "$@"\n`, + { mode: 0o755 }, + ); + } + fs.mkdirSync(path.join(tempRepoRoot, 'apps/worker/dist'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tempRepoRoot, 'apps/worker/dist/payload.bin'), + randomBytes(1024 * 1024), + ); + + const result = await expectFailedPackagingPreservesRelease(); + expect(result.stderr).toMatch(/File size limit exceeded/i); + }); + + it('preserves the last release when neither checksum command succeeds', async () => { + for (const command of ['sha256sum', 'shasum']) { + fs.writeFileSync(path.join(fakeBinDir, command), '#!/bin/sh\nexit 42\n', { + mode: 0o755, + }); + } + + const result = await expectFailedPackagingPreservesRelease(); + expect(result.exitCode).toBe(42); }); + + async function expectFailedPackagingPreservesRelease() { + const archivePath = path.join(outputDir, 'worker-vlocal-dev.tar.gz'); + const checksumPath = `${archivePath}.sha256`; + const stagingRoot = path.join(tempDir, 'staging'); + fs.mkdirSync(stagingRoot); + fs.writeFileSync(archivePath, 'previous archive'); + fs.writeFileSync(checksumPath, 'previous checksum'); + // An unrelated directory must not become this build's staging tree. + const existingDirectory = path.join(tempRepoRoot, 'worker-vlocal-dev'); + fs.mkdirSync(existingDirectory); + fs.writeFileSync(path.join(existingDirectory, 'keep'), 'untouched'); + + const result = await execa( + 'bash', + [tempScriptPath, 'local-dev', '--output-dir', outputDir], + { + cwd: tempRepoRoot, + reject: false, + env: { + BASH_ENV: '/dev/null', + PATH: `${fakeBinDir}:${process.env.PATH ?? ''}`, + PNPM_LOG: pnpmLogPath, + TMPDIR: stagingRoot, + }, + }, + ); + + expect(result.exitCode).not.toBe(0); + expect(fs.readFileSync(archivePath, 'utf8')).toBe('previous archive'); + expect(fs.readFileSync(checksumPath, 'utf8')).toBe('previous checksum'); + expect(fs.readdirSync(stagingRoot)).toEqual([]); + expect(fs.readdirSync(outputDir).sort()).toEqual([ + 'worker-vlocal-dev.tar.gz', + 'worker-vlocal-dev.tar.gz.sha256', + ]); + expect(fs.readFileSync(path.join(existingDirectory, 'keep'), 'utf8')).toBe( + 'untouched', + ); + return result; + } }); diff --git a/packages/compute-providers/src/adapters/modal.test.ts b/packages/compute-providers/src/adapters/modal.test.ts index 1857bcc186..d790774aac 100644 --- a/packages/compute-providers/src/adapters/modal.test.ts +++ b/packages/compute-providers/src/adapters/modal.test.ts @@ -1342,7 +1342,7 @@ describe('ModalClient', () => { expect(buildScript).toContain('NODE_PTY_VERSION="$('); expect(buildScript).toContain( - 'echo "$NODE_PTY_VERSION" > "$TAG/NODE_PTY_VERSION"', + 'echo "$NODE_PTY_VERSION" > "$PACKAGE_DIR/NODE_PTY_VERSION"', ); }); }); diff --git a/scripts/build-worker-release.sh b/scripts/build-worker-release.sh index 963482cb31..287240892a 100755 --- a/scripts/build-worker-release.sh +++ b/scripts/build-worker-release.sh @@ -142,51 +142,65 @@ if [[ "$PUBLISH" = true && -n "${SENTRY_AUTH_TOKEN:-}" ]]; then apps/worker/dist fi +# Each build owns its staging tree and unpublished files. A failed package must +# leave the previous release and its checksum usable. +STAGING_DIR=$(mktemp -d) +ARCHIVE_TMP="${ARCHIVE_PATH}.tmp.$$" +CHECKSUM_TMP="${CHECKSUM_PATH}.tmp.$$" +cleanup() { + rm -rf "$STAGING_DIR" + rm -f "$ARCHIVE_TMP" "$CHECKSUM_TMP" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +PACKAGE_DIR="$STAGING_DIR/$TAG" + # Package -rm -rf "$TAG" "$CHECKSUM_PATH" -mkdir -p "$TAG/dist" "$TAG/.agents/skills" +mkdir -p "$PACKAGE_DIR/dist" "$PACKAGE_DIR/.agents/skills" # Copy the entire worker dist tree so runtime assets such as bundled patch files # ship alongside the JS entrypoints they are resolved from. -cp -R apps/worker/dist/. "$TAG/dist/" +cp -R apps/worker/dist/. "$PACKAGE_DIR/dist/" # Strip sourcemaps from the packaged release. Sourcemaps embed the original # TypeScript via sourcesContent, so they must not ship in release archives # (which are baked into the app image and injected into task sandboxes). # The Sentry upload above reads from apps/worker/dist and is unaffected. -find "$TAG/dist" -type f -name '*.map' -delete +find "$PACKAGE_DIR/dist" -type f -name '*.map' -delete # MCP configs go into .agents/ (Claude Code reads them). -[ -d ".docker/config/mcp" ] && cp -r .docker/config/mcp "$TAG/.agents/" +[ -d ".docker/config/mcp" ] && cp -r .docker/config/mcp "$PACKAGE_DIR/.agents/" # Packaged skills source folders live outside .agents/ so they don't pollute the # agent home. activateSkillsFolder() copies the selected one into .agents/skills/. -[ -d "packages/cloud-agents/src/server/workflows/skills" ] && cp -r packages/cloud-agents/src/server/workflows/skills "$TAG/.packaged-skills" +[ -d "packages/cloud-agents/src/server/workflows/skills" ] && cp -r packages/cloud-agents/src/server/workflows/skills "$PACKAGE_DIR/.packaged-skills" # Use the skill shipped with the same pinned package as the bundled CLI. -cp -R apps/worker/dist/skills/judgement "$TAG/.packaged-skills/standard/judgement" -echo "$VERSION" > "$TAG/VERSION" -echo "${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}" > "$TAG/COMMIT" -echo "$NODE_PTY_VERSION" > "$TAG/NODE_PTY_VERSION" -echo "$PM2_VERSION" > "$TAG/PM2_VERSION" +cp -R apps/worker/dist/skills/judgement "$PACKAGE_DIR/.packaged-skills/standard/judgement" +echo "$VERSION" > "$PACKAGE_DIR/VERSION" +echo "${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}" > "$PACKAGE_DIR/COMMIT" +echo "$NODE_PTY_VERSION" > "$PACKAGE_DIR/NODE_PTY_VERSION" +echo "$PM2_VERSION" > "$PACKAGE_DIR/PM2_VERSION" # Strip macOS xattrs if needed -command -v xattr &>/dev/null && xattr -cr "$TAG" 2>/dev/null || true +command -v xattr &>/dev/null && xattr -cr "$PACKAGE_DIR" 2>/dev/null || true # Create release archive atomically to avoid race with fs.watch consumers. # Write to a temp file first, then mv to the final path so watchers only # ever see a complete archive. -ARCHIVE_TMP="${ARCHIVE_PATH}.tmp.$$" if command -v gtar &>/dev/null; then - gtar -czf "$ARCHIVE_TMP" "$TAG" + gtar -czf "$ARCHIVE_TMP" -C "$STAGING_DIR" "$TAG" elif [[ "$OSTYPE" == "darwin"* ]]; then - COPYFILE_DISABLE=1 tar --no-xattrs -czf "$ARCHIVE_TMP" "$TAG" 2>/dev/null || COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_TMP" "$TAG" + COPYFILE_DISABLE=1 tar --no-xattrs -czf "$ARCHIVE_TMP" -C "$STAGING_DIR" "$TAG" 2>/dev/null || COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_TMP" -C "$STAGING_DIR" "$TAG" else - tar -czf "$ARCHIVE_TMP" "$TAG" + tar -czf "$ARCHIVE_TMP" -C "$STAGING_DIR" "$TAG" fi -mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH" -# Checksum -CHECKSUM=$(sha256sum "$ARCHIVE_PATH" 2>/dev/null || shasum -a 256 "$ARCHIVE_PATH") +# Compute both outputs before replacing either previous release file. +CHECKSUM=$(sha256sum "$ARCHIVE_TMP" 2>/dev/null || shasum -a 256 "$ARCHIVE_TMP") CHECKSUM="${CHECKSUM%% *}" # Extract just the hash (before the double-space separator) -echo "$CHECKSUM $ARCHIVE_NAME" > "$CHECKSUM_PATH" +echo "$CHECKSUM $ARCHIVE_NAME" > "$CHECKSUM_TMP" +mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH" +mv -f "$CHECKSUM_TMP" "$CHECKSUM_PATH" -rm -rf "$TAG" +cleanup echo "✓ Created $ARCHIVE_PATH ($(ls -lh "$ARCHIVE_PATH" | awk '{print $5}'))" # CI outputs diff --git a/scripts/release/__tests__/workflow.test.mjs b/scripts/release/__tests__/workflow.test.mjs index bf0033b505..30ed26e2c0 100644 --- a/scripts/release/__tests__/workflow.test.mjs +++ b/scripts/release/__tests__/workflow.test.mjs @@ -289,7 +289,7 @@ test('GHCR app and embedded worker use the checked-out build commit', () => { ); assert.match( workerRelease, - /^echo "\$\{GITHUB_SHA:-.*\}" > "\$TAG\/COMMIT"$/m, + /^echo "\$\{GITHUB_SHA:-.*\}" > "\$PACKAGE_DIR\/COMMIT"$/m, ); });