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..b3da742d --- /dev/null +++ b/.github/workflows/functions-compiler-publish.yml @@ -0,0 +1,143 @@ +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 + + # 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 }} + + - 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 ${{ 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 }} + 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..7a397e17 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -174,3 +174,30 @@ 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, 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. + +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. 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" }, 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";