diff --git a/.github/scripts/check_wix_proxy_steps.py b/.github/scripts/check_wix_proxy_steps.py index 8110a74d..c33e95a9 100644 --- a/.github/scripts/check_wix_proxy_steps.py +++ b/.github/scripts/check_wix_proxy_steps.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Fail if any GitHub Actions job skips the mandatory Wix gateway proxy action. -There is no opt-out marker by design. A job that genuinely cannot run the proxy -(a non-ubuntu runner, say) changes this script in the same PR, so the exception -gets reviewed in the open. +There is no per-job opt-out marker by design. A job that genuinely cannot run the +proxy changes this script in the same PR, so the exception gets reviewed in the +open — which is exactly how PUBLISH_WORKFLOWS below came to exist. """ from __future__ import annotations @@ -19,6 +19,19 @@ # so a sparse checkout has to materialize both directories. REQUIRED_PATHS = (".github/actions/wix-gateway-proxy", ".github/certs") +# Publish workflows, exempt from the gateway by secplatform's interim policy for +# OSS repos: the gateway cannot carry `npm publish`, so these rely on the +# committed lockfile plus bunfig.toml's minimumReleaseAge instead. +# +# Adding a file here drops its embargo protection. That is a security decision, +# not a formality — do not do it lightly. +PUBLISH_WORKFLOWS = frozenset( + { + ".github/workflows/manual-publish.yml", + ".github/workflows/preview-publish.yml", + } +) + FIX_HINT = """Every job must run the Wix gateway proxy immediately after a checkout that puts it on disk, or that job's npm installs bypass the Wix embargo gateway. @@ -134,8 +147,13 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: workflows = frozenset(p.relative_to(repo_root).as_posix() for p in paths) problems = [] jobs = calls = 0 + exempt_paths = set() for path in paths: + rel = path.relative_to(repo_root).as_posix() + if rel in PUBLISH_WORKFLOWS: + exempt_paths.add(rel) + continue text = path.read_text(encoding="utf-8") lines = _job_lines(text) for job_id, job in ((yaml.safe_load(text) or {}).get("jobs") or {}).items(): @@ -156,9 +174,11 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: print( f"Wix gateway proxy: verified {jobs - calls} of {jobs} jobs across " - f"{len(paths)} workflows ({calls} reusable-workflow calls delegate to " - f"the workflow they call)." + f"{len(paths) - len(exempt_paths)} workflows ({calls} reusable-workflow calls " + f"delegate to the workflow they call)." ) + if exempt_paths: + print("Publish workflows exempt by policy: " + ", ".join(sorted(exempt_paths))) return 0 diff --git a/.github/scripts/test_check_wix_proxy_steps.py b/.github/scripts/test_check_wix_proxy_steps.py index a407a0f4..5345b712 100644 --- a/.github/scripts/test_check_wix_proxy_steps.py +++ b/.github/scripts/test_check_wix_proxy_steps.py @@ -331,6 +331,48 @@ def test_job_with_no_body_is_reported_as_missing_the_proxy(self): self.assertIn('Job "build" does not run the Wix gateway proxy.', output) +class PublishExemptionTests(unittest.TestCase): + """Workflows listed in PUBLISH_WORKFLOWS are skipped; everything else is checked.""" + + PUBLISH_WITHOUT_PROXY = textwrap.dedent("""\ + name: Manual Package Publish + on: + workflow_dispatch: + + jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm publish + """) + + def test_publish_workflow_may_omit_the_proxy(self): + with fixture_repo(**{"manual-publish": self.PUBLISH_WITHOUT_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("exempt by policy", output) + self.assertIn(".github/workflows/manual-publish.yml", output) + + def test_exempt_jobs_are_not_counted_as_verified(self): + with fixture_repo( + **{"manual-publish": self.PUBLISH_WITHOUT_PROXY, "good": COMPLIANT_WORKFLOW} + ) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("verified 1 of 1 jobs across 1 workflows", output) + + def test_a_workflow_not_on_the_list_still_needs_the_proxy(self): + with fixture_repo(**{"some-publish-helper": self.PUBLISH_WITHOUT_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 1) + self.assertIn("does not run the Wix gateway proxy", output) + + class RepositoryTests(unittest.TestCase): def test_every_job_in_this_repository_runs_the_proxy(self): self.assertEqual(checker.main(), 0) diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 89234c24..31b0dfb6 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -27,19 +27,22 @@ on: env: CLI_PACKAGE_DIR: packages/cli +# 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, so dropping the +# gateway does not widen what it can pull. Exemption lives in +# .github/scripts/check_wix_proxy_steps.py. jobs: publish: runs-on: ubuntu-latest + permissions: + # contents: write for the release commit, tag, and GitHub Release. + # id-token: write for npm trusted publishing (OIDC). + contents: write + id-token: write steps: - - name: Checkout for wix gateway proxy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - sparse-checkout: .github - - - name: Wix gateway proxy (mandatory) - uses: ./.github/actions/wix-gateway-proxy - - name: Generate a token id: generate-token uses: actions/create-github-app-token@v2 @@ -60,8 +63,8 @@ jobs: node-version-file: ".node-version" registry-url: "https://registry.npmjs.org" - - name: Update npm - run: npm install -g npm@latest + # 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 @@ -82,13 +85,11 @@ jobs: - name: Set version working-directory: ${{ env.CLI_PACKAGE_DIR }} + # `npm version` takes both a keyword (patch/minor/major) and an explicit + # version, so it replaces the old branch. It also replaces `bunx json-bump`, + # which fetched an undeclared package at run time, outside the cooldown. run: | - VERSION_INPUT="${{ github.event.inputs.version }}" - if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then - bunx json-bump package.json --$VERSION_INPUT - else - bunx json-bump package.json --replace="$VERSION_INPUT" - fi + 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 @@ -120,11 +121,11 @@ jobs: echo "NPM tag: ${{ github.event.inputs.npm_tag }}" echo "Dry run: ${{ github.event.inputs.dry_run }}" - - name: Unpin npm registry for first-party publish - # The embargo would refuse the just-built version; installs above stayed gatewayed. - run: sudo sed -i '/registry\.npmjs\.org/d' /etc/hosts - - name: Publish to NPM + # Authenticates via npm trusted publishing (OIDC), so no NPM_TOKEN reaches + # the build. Needs a trusted publisher for `base44` on npmjs.com registered + # against this repo and this workflow filename — the registry keys on the + # filename, so each publish workflow needs its own entry. working-directory: ${{ env.CLI_PACKAGE_DIR }} run: | # Remove devDependencies before publish (everything is bundled) @@ -190,9 +191,3 @@ jobs: "release_url": "${{ env.RELEASE_URL }}", "release_name": "Release v${{ env.NEW_VERSION }}" } - -permissions: - contents: write - packages: write - pull-requests: read - id-token: write diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 225235f9..010cedb9 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -4,9 +4,21 @@ on: pull_request: types: [opened, synchronize, reopened] +# 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, so dropping the +# gateway does not widen what it can pull. Exemption lives in +# .github/scripts/check_wix_proxy_steps.py. jobs: publish-preview: runs-on: ubuntu-latest + permissions: + # id-token: write for npm trusted publishing (OIDC). + # pull-requests: write for the install-instructions comment. + contents: read + id-token: write + pull-requests: write defaults: run: working-directory: packages/cli @@ -15,19 +27,14 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Wix gateway proxy (mandatory) - uses: ./.github/actions/wix-gateway-proxy - + # 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 Node.js uses: actions/setup-node@v4 with: node-version-file: ".node-version" registry-url: "https://registry.npmjs.org" - - name: Update npm - run: npm install -g npm@latest - working-directory: . - - name: Setup Bun id: setup-bun uses: oven-sh/setup-bun@v2 @@ -90,14 +97,14 @@ jobs: exit 1 fi - # Update name with error handling - if ! bunx json-bump package.json --entry=name --replace="$PREVIEW_PACKAGE"; then + # `npm pkg set` replaces `bunx json-bump`, which fetched an undeclared + # package at run time, outside the cooldown. + if ! npm pkg set name="$PREVIEW_PACKAGE"; then echo "❌ ERROR: Failed to set package name to $PREVIEW_PACKAGE" exit 1 fi - # Update version with error handling - if ! bunx json-bump package.json --replace="${{ steps.preview_info.outputs.version }}"; then + if ! npm pkg set version="${{ steps.preview_info.outputs.version }}"; then echo "❌ ERROR: Failed to set package version to ${{ steps.preview_info.outputs.version }}" exit 1 fi @@ -120,13 +127,12 @@ jobs: echo "✅ Safety check passed. Package name is safe to publish." - - name: Unpin npm registry for first-party publish - # The embargo would refuse the just-built version; installs above stayed gatewayed. - run: sudo sed -i '/registry\.npmjs\.org/d' /etc/hosts - - name: Publish preview package - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Authenticates via npm trusted publishing (OIDC), so no NPM_TOKEN reaches + # the build. Needs a trusted publisher for `@base44-preview/cli` on + # npmjs.com registered against this repo and this workflow filename — the + # registry keys on the filename, so this needs its own entry, separate from + # manual-publish.yml. run: | # Remove devDependencies before publish (everything is bundled) jq 'del(.devDependencies)' package.json > package.json.tmp && mv package.json.tmp package.json @@ -231,8 +237,3 @@ jobs: await createOrUpdateComment(context.issue.number); } } - -permissions: - contents: read - pull-requests: write - id-token: write diff --git a/.gitignore b/.gitignore index 6ea7b7d3..ed29b187 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,7 @@ error-reports/ # Deno lockfile — generated when running deno against backend-runtime/ or fixtures. # The repo uses Bun for dependencies; this is a local artifact. deno.lock + +# Python bytecode — from running the .github/scripts checks locally. +__pycache__/ +*.pyc diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..e3f3e4de --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +[install] +# Supply-chain cooldown: Bun resolves only versions published at least this long +# ago, so a compromised release cannot enter bun.lock inside the window. Seconds, +# unlike npm's min-release-age (days). Applies to `bun add`/`bun update`, not to +# `bun install --frozen-lockfile` (resolves nothing) and not to `bunx`, which +# accepts the flag without enforcing it (oven-sh/bun#30748). +minimumReleaseAge = 1209600 # 14 days diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 453cff78..d683d139 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -61,7 +61,7 @@ bun run lint:fix # Biome - auto-fix These apply to every task. See topic guides below for domain-specific rules. -1. **Bun for everything** - Use `bun` commands for install, test, build, run +1. **Bun for everything** - Use `bun` commands for install, test, build, run. `bunfig.toml` sets a 14-day `minimumReleaseAge` supply-chain cooldown on `bun add`/`bun update`, so a freshly published version cannot enter `bun.lock`. Never fetch tooling with `bunx` in a release path — it ignores the cooldown ([oven-sh/bun#30748](https://github.com/oven-sh/bun/issues/30748)) 2. **Zod validation** - Required for all external data (API responses, config files) 3. **@clack/prompts only** - For all user interaction (prompts, spinners, logs). No `console.log`. Under the global `--json` flag the lifecycle runs **silent** (prompts and spinners suppressed, logs routed to stderr) — never assume a TTY 4. **ES Modules** - Use `.js` extensions in all imports