From 8707c051820c7f351291e2893ed1c845c5817527 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 17 Sep 2026 18:51:35 +0300 Subject: [PATCH 1/3] ci(functions-compiler): add a manual npm publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's manual-publish.yml cannot be reused for @base44/functions-compiler. Most of it is CLI-specific — standalone binaries, the Homebrew tap, PostHog sourcemaps, the skills-repo dispatch, the GitHub Release that carries the tarballs — and every one of those steps would need an `if:` guard on a package input, in the one job that must not break. The two trains also tag differently: `v` is the CLI's series, so this one tags `functions-compiler-v`. Sharing the file would buy nothing anyway. npm trusted publishing keys a publisher on the repo *and* the workflow filename, so @base44/functions-compiler needs its own registry entry either way; a separate file makes that entry narrower — it can publish this package and nothing else. Two differences from the CLI workflow worth naming: - Nothing is stripped from package.json before publish. The CLI deletes devDependencies because everything is bundled; here esbuild, @deno/loader and zod are real runtime dependencies consumers install. - The job only builds and publishes. The packaging proof that hits the registry (scripts/verify-package.ts) stays in functions-compiler.yml, behind the Wix gateway, where it runs on every push to main — so this job still resolves nothing, which is what its gateway exemption rests on. Before the first run, a trusted publisher for @base44/functions-compiler must be registered on npmjs.com against this repo and this filename. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check_wix_proxy_steps.py | 1 + .../workflows/functions-compiler-publish.yml | 134 ++++++++++++++++++ packages/functions-compiler/README.md | 21 +++ 3 files changed, 156 insertions(+) create mode 100644 .github/workflows/functions-compiler-publish.yml diff --git a/.github/scripts/check_wix_proxy_steps.py b/.github/scripts/check_wix_proxy_steps.py index c33e95a9..0dc91567 100644 --- a/.github/scripts/check_wix_proxy_steps.py +++ b/.github/scripts/check_wix_proxy_steps.py @@ -27,6 +27,7 @@ # not a formality — do not do it lightly. PUBLISH_WORKFLOWS = frozenset( { + ".github/workflows/functions-compiler-publish.yml", ".github/workflows/manual-publish.yml", ".github/workflows/preview-publish.yml", } diff --git a/.github/workflows/functions-compiler-publish.yml b/.github/workflows/functions-compiler-publish.yml new file mode 100644 index 00000000..1542bbaa --- /dev/null +++ b/.github/workflows/functions-compiler-publish.yml @@ -0,0 +1,134 @@ +name: Manual Functions Compiler Publish + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g., 1.0.0, patch, minor, major)" + required: true + default: "patch" + type: string + npm_tag: + description: "NPM tag to publish with (e.g., latest, beta, alpha)" + required: true + default: "latest" + type: string + dry_run: + description: "Run in dry-run mode (no actual publish)" + required: false + default: false + type: boolean + +env: + COMPILER_PACKAGE_DIR: packages/functions-compiler + +# This workflow deliberately does NOT run the Wix gateway proxy: the gateway +# cannot carry `npm publish` (it rejects `PUT /`), so per secplatform's +# interim policy for OSS repos this job relies on `--frozen-lockfile` plus +# bunfig.toml's minimumReleaseAge instead. It resolves nothing — the build is +# local (tsc plus two asset copies) and the packaging proof that does hit the +# registry (`scripts/verify-package.ts`) stays in functions-compiler.yml, behind +# the gateway, where it runs on every push to main. Exemption lives in +# .github/scripts/check_wix_proxy_steps.py. +jobs: + publish: + runs-on: ubuntu-latest + permissions: + # contents: write for the release commit and tag. + # id-token: write for npm trusted publishing (OIDC). + contents: write + id-token: write + + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.BASE44_GITHUB_ACTIONS_APP_ID }} + private-key: ${{ secrets.BASE44_GITHUB_ACTIONS_APP_PRIVATE_KEY }} + owner: base44 + + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: ".node-version" + registry-url: "https://registry.npmjs.org" + + # No `npm install -g npm@latest`: trusted publishing needs npm >= 11.5.1, and + # the npm bundled with .node-version's Node 24 is already newer. + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + # Unpinned, matching functions-compiler.yml. The pin in manual-publish.yml + # exists for the CLI's `build:binaries` cross-compile; this package builds + # with tsc and compiles nothing. + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Set version + working-directory: ${{ env.COMPILER_PACKAGE_DIR }} + # `--no-workspaces` keeps npm from bumping the CLI alongside this package. + # bun.lock records workspace versions but `--frozen-lockfile` tolerates the + # drift, so the bump needs no lockfile update. + run: | + npm version "${{ github.event.inputs.version }}" --no-git-tag-version --no-workspaces + echo "NEW_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Build package + run: bun run build + working-directory: ${{ env.COMPILER_PACKAGE_DIR }} + + - name: Show package info + working-directory: ${{ env.COMPILER_PACKAGE_DIR }} + run: | + echo "Package name: $(node -p "require('./package.json').name")" + echo "Version: ${{ env.NEW_VERSION }}" + echo "NPM tag: ${{ github.event.inputs.npm_tag }}" + echo "Dry run: ${{ github.event.inputs.dry_run }}" + + - name: Publish to NPM + # Authenticates via npm trusted publishing (OIDC), so no NPM_TOKEN reaches + # the build. Needs a trusted publisher for `@base44/functions-compiler` on + # npmjs.com registered against this repo and THIS workflow filename — the + # registry keys on the filename, so this entry is separate from the ones + # for manual-publish.yml and preview-publish.yml. + # + # Unlike the CLI, this package is not bundled: esbuild, @deno/loader and zod + # are real runtime dependencies that consumers install. Nothing is stripped + # from package.json before publish. + working-directory: ${{ env.COMPILER_PACKAGE_DIR }} + run: npm publish --tag ${{ github.event.inputs.npm_tag }} ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} + + - name: Create Git tag + if: github.event.inputs.dry_run == 'false' + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + # Tagged `functions-compiler-v*`, not `v*`: the bare `v*` series belongs to + # the CLI and the two release trains move independently. + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git add ${{ env.COMPILER_PACKAGE_DIR }}/package.json + git commit -m "chore(functions-compiler): release v${{ env.NEW_VERSION }}" + git tag functions-compiler-v${{ env.NEW_VERSION }} + git push origin HEAD:${{ github.ref }} + git push origin functions-compiler-v${{ env.NEW_VERSION }} diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 47957b03..a77ed9f2 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -174,3 +174,24 @@ bun run build # shims + tsc -> lib/ + assets; what gets published a sibling workspace — needs `bun run build` here first. There is deliberately no source-resolving export condition: the tarball ships `lib/` alone, and a second resolution path would mean two answers to "which code ran". + +## Releasing + +Run the **Manual Functions Compiler Publish** workflow +(`.github/workflows/functions-compiler-publish.yml`) from the Actions tab. It +bumps the version, builds `lib/`, publishes to npm, and pushes a +`functions-compiler-v` tag plus the release commit. The CLI's own +release train is a separate workflow with its own `v` tags; the two +never move together. + +Authentication is npm **trusted publishing** (OIDC) — no token in the repo. The +registry keys a trusted publisher on the repo *and the workflow filename*, so +`@base44/functions-compiler` needs its own entry on npmjs.com pointing at +`functions-compiler-publish.yml`; the entries for `manual-publish.yml` do not +cover it. + +The workflow only builds and publishes. What proves the tarball actually works — +`scripts/verify-package.ts`, which packs, installs the tarball into a throwaway +directory and compiles a real function there — runs in `functions-compiler.yml` +on every push to `main`, behind the Wix embargo gateway. Publish from a commit +that went green there. From e7658609a45e0756366766dd06065893200013e8 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 17 Sep 2026 19:08:04 +0300 Subject: [PATCH 2/3] ci(functions-compiler): declare the repository for trusted publishing npm's trusted-publisher validation requires package.json's repository URL to match the GitHub repository the OIDC token comes from. packages/cli already declares it; this package did not, so the first publish would have failed the check with nothing in the workflow to explain why. `directory` points at the package inside the monorepo, which is also what makes the npm page link to the right subtree. Co-Authored-By: Claude Opus 5 (1M context) --- packages/functions-compiler/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/functions-compiler/package.json b/packages/functions-compiler/package.json index 6ed9ca49..65ada796 100644 --- a/packages/functions-compiler/package.json +++ b/packages/functions-compiler/package.json @@ -3,6 +3,11 @@ "version": "0.1.1", "description": "Production compiler for Base44 backend functions — turns function sources into a single Cloudflare Workers module.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/base44/cli", + "directory": "packages/functions-compiler" + }, "publishConfig": { "access": "public" }, From d3209726a1a8d0f7586aeb8b6e2e9b5883c17798 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Thu, 17 Sep 2026 19:14:52 +0300 Subject: [PATCH 3/3] fix(functions-compiler): keep COMPILER_VERSION in step with package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main has been red since #623 landed: it added src/version.ts pinned to 0.1.0 and the test that guards it, having branched before #628 bumped package.json to 0.1.1. Setting the literal to 0.1.1 is the whole of that fix. The publish workflow would have reintroduced the drift on every release. `npm version` rewrites package.json alone, and the literal cannot be read from package.json at run time — a host that bundles this module ships none beside it (that is what src/version.ts documents). So a release would have published a banner naming the previous version and left main red again. scripts/sync-version.ts rewrites the literal from package.json and throws if it matches nothing, because a silent no-op sed is precisely the failure being prevented. The workflow runs it between the bump and the build, and the release commit now carries both files. Bumping by hand runs the same script. Co-Authored-By: Claude Opus 5 (1M context) --- .../workflows/functions-compiler-publish.yml | 11 +++++- packages/functions-compiler/README.md | 10 ++++-- .../scripts/sync-version.ts | 34 +++++++++++++++++++ packages/functions-compiler/src/version.ts | 2 +- 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 packages/functions-compiler/scripts/sync-version.ts diff --git a/.github/workflows/functions-compiler-publish.yml b/.github/workflows/functions-compiler-publish.yml index 1542bbaa..b3da742d 100644 --- a/.github/workflows/functions-compiler-publish.yml +++ b/.github/workflows/functions-compiler-publish.yml @@ -92,6 +92,15 @@ jobs: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --no-workspaces echo "NEW_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + # The version is also a literal in src/version.ts, because it goes into every + # compiled shard's banner and a host that bundles this module ships no + # package.json to read it from. `npm version` does not know about that file; + # without this step the release would publish a banner claiming the previous + # version, and leave main red on version.test.ts. + - name: Sync COMPILER_VERSION + run: bun run scripts/sync-version.ts + working-directory: ${{ env.COMPILER_PACKAGE_DIR }} + - name: Build package run: bun run build working-directory: ${{ env.COMPILER_PACKAGE_DIR }} @@ -127,7 +136,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - git add ${{ env.COMPILER_PACKAGE_DIR }}/package.json + git add ${{ env.COMPILER_PACKAGE_DIR }}/package.json ${{ env.COMPILER_PACKAGE_DIR }}/src/version.ts git commit -m "chore(functions-compiler): release v${{ env.NEW_VERSION }}" git tag functions-compiler-v${{ env.NEW_VERSION }} git push origin HEAD:${{ github.ref }} diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index a77ed9f2..7a397e17 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -179,8 +179,14 @@ second resolution path would mean two answers to "which code ran". Run the **Manual Functions Compiler Publish** workflow (`.github/workflows/functions-compiler-publish.yml`) from the Actions tab. It -bumps the version, builds `lib/`, publishes to npm, and pushes a -`functions-compiler-v` tag plus the release commit. The CLI's own +bumps the version, syncs the `COMPILER_VERSION` literal, builds `lib/`, +publishes to npm, and pushes a `functions-compiler-v` tag plus the +release commit. + +A version lives in two files — `package.json` and the literal in +`src/version.ts` that goes into every compiled shard's banner. Bumping by hand +means running `bun run scripts/sync-version.ts` after editing `package.json`; +`version.test.ts` fails the build if the two drift apart. The CLI's own release train is a separate workflow with its own `v` tags; the two never move together. diff --git a/packages/functions-compiler/scripts/sync-version.ts b/packages/functions-compiler/scripts/sync-version.ts new file mode 100644 index 00000000..ed9853f7 --- /dev/null +++ b/packages/functions-compiler/scripts/sync-version.ts @@ -0,0 +1,34 @@ +// Rewrites the COMPILER_VERSION literal in src/version.ts to match package.json. +// +// The version has to be a literal (see src/version.ts for why), so a release +// edits two files. `npm version` only knows about one of them, and a stale +// literal is invisible: the build succeeds, the publish succeeds, and every +// compiled shard's banner then claims a version that was never published. +// version.test.ts catches the drift, but only after the fact — this closes it. +// +// bun run scripts/sync-version.ts + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const packageJsonPath = fileURLToPath(new URL("../package.json", import.meta.url)); +const versionFilePath = fileURLToPath(new URL("../src/version.ts", import.meta.url)); + +const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version: string }; +if (!version) throw new Error("package.json has no version"); + +const source = readFileSync(versionFilePath, "utf8"); +const literal = /^export const COMPILER_VERSION = "(.*)";$/m; + +const match = source.match(literal); +if (!match) { + // A silent no-op here would ship the exact drift this script exists to stop. + throw new Error(`no COMPILER_VERSION literal found in ${versionFilePath}`); +} + +if (match[1] === version) { + console.log(`COMPILER_VERSION already ${version}`); +} else { + writeFileSync(versionFilePath, source.replace(literal, `export const COMPILER_VERSION = "${version}";`)); + console.log(`COMPILER_VERSION ${match[1]} -> ${version}`); +} diff --git a/packages/functions-compiler/src/version.ts b/packages/functions-compiler/src/version.ts index ccb22ddb..9d41b2de 100644 --- a/packages/functions-compiler/src/version.ts +++ b/packages/functions-compiler/src/version.ts @@ -12,4 +12,4 @@ * drifts from `package.json`, so bumping the package still means editing two * files but cannot mean forgetting one. */ -export const COMPILER_VERSION = "0.1.0"; +export const COMPILER_VERSION = "0.1.1";