diff --git a/.bun-version b/.bun-version index d4c4950..085c0f2 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.9 +1.3.14 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cb7551b..ab7982a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,15 +13,25 @@ permissions: contents: read security-events: write +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze JavaScript and TypeScript - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 + - name: Initialize CodeQL + uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 with: languages: javascript-typescript - - uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 + - name: Analyze repository + uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 2e51954..dc15ef9 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -8,15 +8,23 @@ permissions: contents: read pull-requests: read +concurrency: + group: dependency-review-${{ github.ref }} + cancel-in-progress: true + jobs: dependency-review: name: Review dependency changes - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v4 - continue-on-error: true + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v4 with: fail-on-severity: high license-check: false diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index a7d2d37..d82558e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -5,80 +5,213 @@ on: branches: - main +permissions: {} + +concurrency: + group: release-please + cancel-in-progress: false + jobs: release-please: - runs-on: ubuntu-latest + name: Prepare release + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: write pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} + steps: - - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + - name: Prepare release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 id: release with: token: ${{ secrets.GITHUB_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json - release-artifacts: - name: Build and publish release artifacts - runs-on: ubuntu-latest + release-matrix: + name: Resolve release matrix + needs: [release-please, verify-release] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + + steps: + - name: Check out released revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Export typed release matrix + id: matrix + run: echo "matrix=$(bun run cli/scripts/release.ts matrix)" >> "$GITHUB_OUTPUT" + + release-native: + name: Build and test release binary (${{ matrix.artifact }}) + needs: [release-please, verify-release, release-matrix] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-matrix.outputs.matrix) }} + + steps: + - name: Check out released revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: cli + + - name: Verify release contract + run: bun run release:verify --tag=${{ needs.release-please.outputs.tag_name }} + working-directory: cli + + - name: Compile release binary + run: bun run release:build --target=${{ matrix.target }} + working-directory: cli + + - name: Smoke test release binary + run: bun run release:smoke --target=${{ matrix.target }} + working-directory: cli + + - name: Upload tested release binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} + if-no-files-found: error + retention-days: 30 + + verify-release: + name: Canonical release verification needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} + if: needs.release-please.outputs.release_created == 'true' + permissions: + contents: read + uses: ./.github/workflows/verify.yml + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + release-artifacts: + name: Verify, attest, and publish release + needs: [release-please, release-native] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 25 permissions: artifact-metadata: write attestations: write contents: write id-token: write + steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check out released revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ needs.release-please.outputs.tag_name }} + submodules: recursive - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: Install Node.js for npm provenance + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .node-version registry-url: "https://registry.npmjs.org" - - name: Log build environment - run: bun --version - - - name: Build CLI bundle - run: | - cd cli - bun install --frozen-lockfile - bun run build - - - name: Compile release binaries - run: | - mkdir -p dist - cd cli - bun build --compile --target=bun-darwin-arm64 src/cli.ts --outfile ../dist/altertable-darwin-arm64 - bun build --compile --target=bun-darwin-x64 src/cli.ts --outfile ../dist/altertable-darwin-x64 - bun build --compile --target=bun-linux-x64 src/cli.ts --outfile ../dist/altertable-linux-x64 - bun build --compile --target=bun-linux-arm64 src/cli.ts --outfile ../dist/altertable-linux-arm64 - cp dist/cli.js ../dist/altertable-cli.js - cd ../dist && shasum -a 256 altertable-darwin-* altertable-linux-* altertable-cli.js > checksums.txt - - - name: Attest release assets + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: cli + + - name: Verify release tag and toolchain + run: bun run release:verify --tag=${{ needs.release-please.outputs.tag_name }} + working-directory: cli + + - name: Download tested release binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-altertable-* + path: dist + merge-multiple: true + + - name: Build JavaScript release bundle + run: bun run release:build --bundle + working-directory: cli + + - name: Finalize exact release artifacts + run: bun run release:finalize --tag=${{ needs.release-please.outputs.tag_name }} + working-directory: cli + + - name: Smoke test npm bundle on release toolchain + run: bun run scripts/smoke-npm-bundle.ts + working-directory: cli + + - name: Install minimum supported npm runtime + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.1.0 + + - name: Smoke test exact npm bundle on minimum runtime + run: bun run scripts/smoke-npm-bundle.ts --expected-bun=1.1.0 + working-directory: cli + + - name: Restore release build toolchain + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Verify release checksums + run: sha256sum --check checksums.txt + working-directory: dist + + - name: Attest release provenance uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 with: - subject-path: | - dist/altertable-darwin-* - dist/altertable-linux-* - dist/altertable-cli.js - dist/checksums.txt + subject-checksums: dist/checksums.txt - name: Upload release assets env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${{ needs.release-please.outputs.tag_name }}" dist/altertable-darwin-* dist/altertable-linux-* dist/altertable-cli.js dist/checksums.txt --clobber + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: bun run release:upload-github + working-directory: cli - name: Publish npm package - run: npm publish --provenance --access public + run: bun run release:publish-npm + working-directory: cli + + - name: Publish completed GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: bun run release:publish-github working-directory: cli diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index ca2e800..970e6d8 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -1,4 +1,5 @@ name: Semantic Pull Request + on: pull_request_target: types: @@ -6,12 +7,21 @@ on: - edited - synchronize +permissions: + pull-requests: read + +concurrency: + group: semantic-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: main: name: Validate PR title - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + - name: Validate pull request title + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7bbb9c..2561d83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,133 +6,113 @@ on: pull_request: branches: ["main"] +permissions: + contents: read + +concurrency: + group: test-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - test: - name: Test CLI - runs-on: ubuntu-latest - services: - altertable: - image: ghcr.io/altertable-ai/altertable-mock:latest - ports: - - 15000:15000 - env: - ALTERTABLE_MOCK_USERS: testuser:testpass - options: >- - --health-cmd "exit 0" - --health-interval 5s - --health-timeout 3s - --health-retries 3 - --health-start-period 10s + verify: + name: Canonical verification + uses: ./.github/workflows/verify.yml + with: + ref: ${{ github.sha }} + + release-matrix: + name: Resolve release matrix + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - submodules: recursive + persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile - working-directory: cli + - name: Export typed release matrix + id: matrix + run: echo "matrix=$(bun run cli/scripts/release.ts matrix)" >> "$GITHUB_OUTPUT" - - name: Typecheck - run: bun run typecheck - working-directory: cli + compile: + name: Compile binary (${{ matrix.artifact }}) + needs: [verify, release-matrix] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-matrix.outputs.matrix) }} - - name: Typecheck top-level tests - run: ./cli/node_modules/.bin/tsc -p tsconfig.tests.json + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Lint - run: bun run lint - working-directory: cli + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version - - name: Check formatting - run: bun run format:check + - name: Install dependencies + run: bun install --frozen-lockfile working-directory: cli - - name: Dead code check (knip) - run: bun run knip + - name: Verify release contract + run: bun run release:verify working-directory: cli - - name: Verify OpenAPI generated files are up to date - run: | - bun run generate - git diff --exit-code src/generated/openapi-types.ts src/generated/openapi-operations.ts + - name: Compile hardened standalone binary + run: bun run release:build --target=${{ matrix.target }} working-directory: cli - - name: Unit tests with coverage - run: bun run test:coverage + - name: Smoke test compiled binary + run: bun run release:smoke --target=${{ matrix.target }} working-directory: cli - - name: Upload coverage report + - name: Upload compiled binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-coverage-lcov - path: cli/coverage/lcov.info + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }} if-no-files-found: error + retention-days: 7 - - name: Build JS bundle - run: bun run build - working-directory: cli + required: + name: Required + needs: [verify, release-matrix, compile] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} - - name: Verify package contents - run: bun run pack:check - working-directory: cli - - - name: Verify executable - run: | - chmod +x bin/altertable - bin/altertable --version - bin/altertable --help - - - name: Run top-level CLI behavior tests - run: bun test "$PWD"/tests/*.test.ts - - - name: Run lakehouse integration tests - run: bun test "$PWD"/tests/integration.e2e.ts - - compile: - name: Compile binaries (${{ matrix.artifact }}) - runs-on: ${{ matrix.runs-on }} - needs: test - strategy: - matrix: - include: - - target: bun-linux-x64 - artifact: altertable-linux-x64 - runs-on: ubuntu-latest - - target: bun-linux-arm64 - artifact: altertable-linux-arm64 - runs-on: ubuntu-24.04-arm - - target: bun-darwin-arm64 - artifact: altertable-darwin-arm64 - runs-on: macos-14 - - target: bun-darwin-x64 - artifact: altertable-darwin-x64 - runs-on: macos-15-intel steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version-file: .bun-version - - - name: Install and build - run: | - cd cli - bun install --frozen-lockfile - bun run build - bun build --compile --target=${{ matrix.target }} src/cli.ts --outfile ../dist/${{ matrix.artifact }} - - - name: Smoke test compiled binary + - name: Enforce required CI results + env: + VERIFY_RESULT: ${{ needs.verify.result }} + RELEASE_MATRIX_RESULT: ${{ needs.release-matrix.result }} + COMPILE_RESULT: ${{ needs.compile.result }} run: | - chmod +x dist/${{ matrix.artifact }} - dist/${{ matrix.artifact }} --version - dist/${{ matrix.artifact }} --help - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ matrix.artifact }} - path: dist/${{ matrix.artifact }} + failed=0 + for outcome in \ + "Canonical verification:${VERIFY_RESULT}" \ + "Release matrix:${RELEASE_MATRIX_RESULT}" \ + "Native compilation:${COMPILE_RESULT}" + do + name="${outcome%%:*}" + result="${outcome#*:}" + if [ "$result" != "success" ]; then + echo "::error title=${name} failed::Result: ${result}" + failed=1 + fi + done + exit "$failed" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a7281f7 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,87 @@ +name: Canonical verification + +on: + workflow_call: + inputs: + ref: + description: Git revision to verify + required: true + type: string + +permissions: + contents: read + +jobs: + repository: + name: Repository and integration + runs-on: ubuntu-24.04 + timeout-minutes: 20 + services: + altertable: + image: ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977 + ports: + - 15000:15000 + env: + ALTERTABLE_MOCK_USERS: testuser:testpass + options: >- + --health-cmd "bash -c '=1.1.0`). Standalone builds reject unresolved imports, disable +automatic `.env` and Bun configuration loading, and use the baseline Linux x64 target for broad CPU +compatibility. + +Smoke-test a compiled binary through the same command used by CI: ```bash -chmod +x ../dist/altertable-linux-x64 -../dist/altertable-linux-x64 --version -../dist/altertable-linux-x64 --help +bun run release:smoke --target=bun-linux-x64-baseline ``` ## Versioning @@ -79,13 +87,40 @@ The CLI version comes from `cli/src/version.ts` (`altertable --version`). [relea On push to `main`, `.github/workflows/release-please.yml`: 1. Opens or merges a release-please PR that bumps version and changelog. -2. When a release is created, builds `cli/dist/cli.js`, compiles four native binaries, copies the bundle to `dist/altertable-cli.js`, writes `dist/checksums.txt` (SHA-256 for every asset), attests release assets, uploads all files to the GitHub Release, and publishes `@altertable/cli` to npm with provenance. - -CI (`.github/workflows/test.yml`) runs the same `bun run build`, `bun run pack:check`, and native compile smoke tests for Linux and macOS release targets so release artifacts are verified before merge. +2. When a release is created, keeps its GitHub Release in draft form and invokes the same reusable + canonical verification workflow used by branch CI for the exact release tag. +3. Native runners compile and smoke-test each platform binary. The final job downloads those exact + tested bytes, builds `cli/dist/cli.js` once, stages the identical bytes as + `dist/altertable-cli.js`, and smoke-tests that npm bundle on both the release toolchain and Bun + 1.1.0. It then writes artifact-specific recipes in `dist/release-manifest.json`, verifies + `dist/checksums.txt`, and attests and uploads every checksummed asset. +4. npm publication is idempotent: retries skip a package version already present in the registry. + The completed GitHub Release becomes public only after npm publication succeeds. + +Both `.github/workflows/test.yml` and the release workflow call `.github/workflows/verify.yml`, which +runs the repository and integration gates and executes the npm bundle on the minimum supported Bun +runtime. Branch CI then loads its native compile matrix from the typed release manifest and +smoke-tests every Linux and macOS release target. Main-branch verification is never cancelled by a +newer push. Workflows use explicit permissions, pinned runners/actions, concurrency controls, and job +timeouts; behavioral probes live in checked-in scripts rather than inline workflow programs. ### npm publish -The `@altertable/cli` package is published to npm on each release by `.github/workflows/release-please.yml` using the `NPM_TOKEN` repository secret and npm provenance. Install globally with `npm install -g @altertable/cli`; npm installs require Bun at runtime, while prebuilt binaries do not. +The `@altertable/cli` package is published to npm on each release by `.github/workflows/release-please.yml` through npm trusted publishing. The workflow uses GitHub Actions OIDC, carries no long-lived npm publishing token, and receives automatic npm provenance. Install globally with `npm install -g @altertable/cli`; npm installs require Bun at runtime, while prebuilt binaries do not. + +The npm package trust relationship must authorize GitHub repository +`altertable-ai/altertable-cli`, workflow file `release-please.yml`, and the `npm publish` +action. The cutover is intentionally staged: + +1. Configure that trusted publisher on npm before merging the tokenless workflow. +2. Publish the next release and verify its npm provenance points to the expected + GitHub Actions run. +3. Set npm publishing access to require 2FA and disallow token publishing. +4. Delete the unused `NPM_TOKEN` GitHub Actions secret and revoke its npm token. + +Do not perform steps 3–4 until an OIDC publication succeeds. This preserves the +rollback path during migration without allowing the workflow to fall back to the +long-lived token. ### Update installer @@ -156,7 +191,7 @@ Integration tests against the mock server: ```bash docker run -d --rm --name at-mock -p 15000:15000 \ -e ALTERTABLE_MOCK_USERS=testuser:testpass \ - ghcr.io/altertable-ai/altertable-mock:latest + ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977 bun test "$PWD"/tests/integration.e2e.ts docker stop at-mock ``` diff --git a/Makefile b/Makefile index 1ffd33e..9479682 100644 --- a/Makefile +++ b/Makefile @@ -1,46 +1,17 @@ # Altertable CLI — build automation # # `make` (default) compiles a standalone native binary for the current -# OS/architecture into dist/. Run from the repository root. +# OS/architecture into dist/. The typed release manifest in +# cli/src/release-manifest.ts owns targets and artifact names. CLI_DIR := cli -SRC := src/cli.ts - -# --- Detect host OS/architecture and map to Bun's --target names ---------- -UNAME_S := $(shell uname -s) -UNAME_M := $(shell uname -m) - -ifeq ($(UNAME_S),Darwin) - OS := darwin -else ifeq ($(UNAME_S),Linux) - OS := linux -else - $(error Unsupported OS '$(UNAME_S)' — supported: Darwin, Linux) -endif - -ifeq ($(UNAME_M),arm64) - ARCH := arm64 -else ifeq ($(UNAME_M),aarch64) - ARCH := arm64 -else ifeq ($(UNAME_M),x86_64) - ARCH := x64 -else ifeq ($(UNAME_M),amd64) - ARCH := x64 -else - $(error Unsupported architecture '$(UNAME_M)' — supported: arm64/aarch64, x86_64/amd64) -endif - -TARGET := bun-$(OS)-$(ARCH) -BINARY := dist/altertable-$(OS)-$(ARCH) .DEFAULT_GOAL := build # --- Compile native binary for the current architecture ------------------- .PHONY: build build: $(CLI_DIR)/node_modules - @mkdir -p dist - cd $(CLI_DIR) && bun build --compile --target=$(TARGET) $(SRC) --outfile ../$(BINARY) - @echo "Built $(BINARY) (target $(TARGET))" + cd $(CLI_DIR) && bun run release:build --native # Install dependencies only when the lockfile is newer than node_modules. $(CLI_DIR)/node_modules: $(CLI_DIR)/bun.lock $(CLI_DIR)/package.json @@ -53,11 +24,7 @@ install: $(CLI_DIR)/node_modules # --- Cross-compile every released target ---------------------------------- .PHONY: cross cross: $(CLI_DIR)/node_modules - @mkdir -p dist - cd $(CLI_DIR) && bun build --compile --target=bun-darwin-arm64 $(SRC) --outfile ../dist/altertable-darwin-arm64 - cd $(CLI_DIR) && bun build --compile --target=bun-darwin-x64 $(SRC) --outfile ../dist/altertable-darwin-x64 - cd $(CLI_DIR) && bun build --compile --target=bun-linux-x64 $(SRC) --outfile ../dist/altertable-linux-x64 - cd $(CLI_DIR) && bun build --compile --target=bun-linux-arm64 $(SRC) --outfile ../dist/altertable-linux-arm64 + cd $(CLI_DIR) && bun run release:build --all # --- Checks --------------------------------------------------------------- .PHONY: test typecheck lint @@ -78,7 +45,7 @@ clean: .PHONY: help help: @echo "Targets:" - @echo " build Compile native binary for this host ($(TARGET)) [default]" + @echo " build Compile native binary for this host [default]" @echo " cross Cross-compile all four released targets" @echo " install Install CLI dependencies (bun install)" @echo " test Run unit tests (bun test)" diff --git a/README.md b/README.md index 20cf9ed..ffe31e4 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Download the platform binary from [GitHub Releases](https://github.com/altertabl | `altertable-linux-arm64` | Linux ARM64 | | `altertable-cli.js` | Bun bundle (`bun altertable-cli.js`) | | `checksums.txt` | SHA-256 checksums for all assets | +| `release-manifest.json` | Versioned artifact and build metadata | Verify and install: diff --git a/cli/bun.lock b/cli/bun.lock index 2d8fb90..5da5f1f 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -8,6 +8,7 @@ "@clack/prompts": "1.7.0", "citty": "0.2.2", "oauth4webapi": "3.8.6", + "yaml": "2.9.0", }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/cli/package.json b/cli/package.json index 6b8eb00..14041e2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,14 +30,23 @@ "test:coverage": "bun test --coverage", "generate": "bun run scripts/generate-openapi-types.ts && bun run scripts/generate-openapi-index.ts", "spec:refresh": "curl -fsSL https://app.altertable.ai/rest/v1/openapi.yaml -o openapi/openapi.yaml && bun run generate && oxfmt openapi/openapi.yaml", - "build": "bun build src/cli.ts --outdir dist --target bun", + "build": "bun run scripts/npm-bundle.ts", + "release:build": "bun run scripts/release.ts build", + "release:finalize": "bun run scripts/release.ts finalize", + "release:matrix": "bun run scripts/release.ts matrix", + "release:publish-github": "bun run scripts/github-release.ts publish", + "release:publish-npm": "bun run scripts/publish-npm.ts", + "release:smoke": "bun run scripts/release.ts smoke", + "release:verify": "bun run scripts/release.ts verify", + "release:upload-github": "bun run scripts/github-release.ts upload", "pack:check": "bun run build && bun pm pack --dry-run", "knip": "knip" }, "dependencies": { "@clack/prompts": "1.7.0", "citty": "0.2.2", - "oauth4webapi": "3.8.6" + "oauth4webapi": "3.8.6", + "yaml": "2.9.0" }, "devDependencies": { "@types/bun": "1.3.14", @@ -52,5 +61,5 @@ "engines": { "bun": ">=1.1.0" }, - "packageManager": "bun@1.3.9" + "packageManager": "bun@1.3.14" } diff --git a/cli/scripts/github-release.ts b/cli/scripts/github-release.ts new file mode 100644 index 0000000..0a709e1 --- /dev/null +++ b/cli/scripts/github-release.ts @@ -0,0 +1,55 @@ +import { join } from "node:path"; +import { + RELEASE_BUNDLE_ASSET, + RELEASE_CHECKSUMS_ASSET, + RELEASE_METADATA_ASSET, + RELEASE_TARGETS, +} from "@/release-manifest.ts"; + +const repositoryRoot = join(import.meta.dir, "../.."); +const releaseDirectory = join(repositoryRoot, "dist"); + +export function githubReleaseUploadCommand(tag: string): string[] { + return [ + "gh", + "release", + "upload", + tag, + ...RELEASE_TARGETS.map(({ asset }) => join(releaseDirectory, asset)), + join(releaseDirectory, RELEASE_BUNDLE_ASSET), + join(releaseDirectory, RELEASE_METADATA_ASSET), + join(releaseDirectory, RELEASE_CHECKSUMS_ASSET), + "--clobber", + ]; +} + +export function githubReleasePublishCommand(tag: string): string[] { + return ["gh", "release", "edit", tag, "--draft=false", "--latest"]; +} + +async function run(command: string[]): Promise { + const child = Bun.spawn(command, { cwd: repositoryRoot, stdout: "inherit", stderr: "inherit" }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + } +} + +async function main(arguments_: string[]): Promise { + const command = arguments_[0]; + const tag = process.env.RELEASE_TAG?.trim(); + if (!tag) throw new Error("RELEASE_TAG is required."); + if (command === "upload") { + await run(githubReleaseUploadCommand(tag)); + return; + } + if (command === "publish") { + await run(githubReleasePublishCommand(tag)); + return; + } + throw new Error("Usage: github-release.ts "); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/cli/scripts/npm-bundle.ts b/cli/scripts/npm-bundle.ts new file mode 100644 index 0000000..1ff749e --- /dev/null +++ b/cli/scripts/npm-bundle.ts @@ -0,0 +1,34 @@ +import { mkdir } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +export const NPM_BUNDLE_OPTIONS = { + target: "bun", + minify: false, + sourcemap: "none", +} as const; + +const repositoryRoot = join(import.meta.dir, "../.."); +const entrypoint = join(repositoryRoot, "cli/src/cli.ts"); +export const NPM_BUNDLE_PATH = join(repositoryRoot, "cli/dist/cli.js"); + +export async function buildNpmBundle(outputPath = NPM_BUNDLE_PATH): Promise { + await mkdir(dirname(outputPath), { recursive: true }); + const result = await Bun.build({ + entrypoints: [entrypoint], + outdir: dirname(outputPath), + naming: basename(outputPath), + ...NPM_BUNDLE_OPTIONS, + }); + if (!result.success) { + throw new Error(`npm bundle failed:\n${result.logs.map(String).join("\n")}`); + } + const output = Bun.file(outputPath); + if (!(await output.exists()) || output.size === 0) { + throw new Error(`npm bundle is missing or empty: ${outputPath}.`); + } + return outputPath; +} + +if (import.meta.main) { + console.log(`Built ${await buildNpmBundle()}.`); +} diff --git a/cli/scripts/publish-npm.ts b/cli/scripts/publish-npm.ts new file mode 100644 index 0000000..b6e8c55 --- /dev/null +++ b/cli/scripts/publish-npm.ts @@ -0,0 +1,117 @@ +import packageJson from "../package.json" with { type: "json" }; + +export type NpmVersionLookup = { status: "published"; version: string } | { status: "missing" }; + +export const MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION = "11.5.1"; + +function parseVersion(version: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()); + if (!match) throw new Error(`Invalid npm version: ${version.trim() || ""}.`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function assertTrustedPublishingEnvironment( + environment: Record, + npmVersion: string, +): void { + if (environment.NODE_AUTH_TOKEN || environment.NPM_TOKEN) { + throw new Error("npm publishing must use OIDC; remove long-lived npm publish tokens."); + } + if (!environment.ACTIONS_ID_TOKEN_REQUEST_URL || !environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + throw new Error( + "npm trusted publishing requires GitHub Actions OIDC credentials (id-token: write).", + ); + } + + const actual = parseVersion(npmVersion); + const minimum = parseVersion(MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION); + for (let index = 0; index < actual.length; index += 1) { + if (actual[index]! > minimum[index]!) return; + if (actual[index]! < minimum[index]!) { + throw new Error( + `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}; received ${npmVersion.trim()}.`, + ); + } + } +} + +export function parseNpmVersionLookup( + exitCode: number, + stdout: string, + stderr: string, +): NpmVersionLookup { + if (exitCode === 0) { + const version = JSON.parse(stdout) as unknown; + if (typeof version !== "string") { + throw new Error(`npm view returned an invalid version response: ${stdout.trim()}`); + } + return { status: "published", version }; + } + if (/\bE404\b|404 Not Found/i.test(`${stdout}\n${stderr}`)) { + return { status: "missing" }; + } + throw new Error(`npm view failed (${exitCode}): ${stderr.trim() || stdout.trim()}`); +} + +export function npmPublicationRequired(lookup: NpmVersionLookup, expectedVersion: string): boolean { + if (lookup.status === "missing") return true; + if (lookup.version !== expectedVersion) { + throw new Error(`npm returned version ${lookup.version}; expected ${expectedVersion}.`); + } + return false; +} + +async function npmVersionLookup(specification: string): Promise { + const child = Bun.spawn(["npm", "view", specification, "version", "--json"], { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return parseNpmVersionLookup(exitCode, stdout, stderr); +} + +async function publish(): Promise { + const versionProcess = Bun.spawn(["npm", "--version"], { + stdout: "pipe", + stderr: "pipe", + }); + const [npmVersion, versionError, versionExitCode] = await Promise.all([ + new Response(versionProcess.stdout).text(), + new Response(versionProcess.stderr).text(), + versionProcess.exited, + ]); + if (versionExitCode !== 0) { + throw new Error( + `npm --version failed (${versionExitCode}): ${versionError.trim() || npmVersion.trim()}`, + ); + } + assertTrustedPublishingEnvironment(Bun.env, npmVersion); + + const child = Bun.spawn(["npm", "publish", "--access", "public"], { + cwd: import.meta.dir + "/..", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`npm publish failed with exit code ${exitCode}.`); + } +} + +export async function publishNpmIfMissing(): Promise { + const specification = `${packageJson.name}@${packageJson.version}`; + const lookup = await npmVersionLookup(specification); + if (!npmPublicationRequired(lookup, packageJson.version)) { + console.log(`${specification} is already published; continuing.`); + return; + } + await publish(); +} + +if (import.meta.main) { + await publishNpmIfMissing(); +} diff --git a/cli/scripts/release.ts b/cli/scripts/release.ts new file mode 100644 index 0000000..cea9ca3 --- /dev/null +++ b/cli/scripts/release.ts @@ -0,0 +1,425 @@ +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, rename, stat } from "node:fs/promises"; +import { join } from "node:path"; +import packageJson from "../package.json" with { type: "json" }; +import { buildNpmBundle, NPM_BUNDLE_OPTIONS, NPM_BUNDLE_PATH } from "@/../scripts/npm-bundle.ts"; +import { VERSION } from "@/version.ts"; +import { + findReleaseTargetByBunTarget, + findReleaseTargetByPlatform, + RELEASE_BUNDLE_ASSET, + RELEASE_CHECKSUMS_ASSET, + RELEASE_METADATA_ASSET, + RELEASE_TARGETS, + releaseCiMatrix, + type ReleaseTarget, +} from "@/release-manifest.ts"; + +export const RELEASE_MANIFEST_SCHEMA_VERSION = 2; +export const SUPPORTED_BUN_RUNTIME_RANGE = ">=1.1.0"; + +export const HARDENED_COMPILE_FLAGS = [ + "--compile", + "--minify", + "--bytecode", + "--format=esm", + "--sourcemap=inline", + "--reject-unresolved", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", +] as const; + +export { NPM_BUNDLE_OPTIONS as JAVASCRIPT_BUNDLE_OPTIONS }; + +const repositoryRoot = join(import.meta.dir, "../.."); +const cliRoot = join(repositoryRoot, "cli"); +const entrypoint = join(cliRoot, "src/cli.ts"); +const defaultOutputDirectory = join(repositoryRoot, "dist"); +const bunVersionFile = join(repositoryRoot, ".bun-version"); + +type PackageJson = typeof packageJson & { + engines: { bun: string }; + packageManager: string; +}; + +export type ReleaseArtifact = { + kind: "native" | "javascript"; + file: string; + bytes: number; + sha256: string; + platform?: string; + bunTarget?: string; + recipe: + | { + builder: "bun-build-executable"; + bunVersion: string; + target: string; + flags: readonly string[]; + } + | { + builder: "bun-build"; + bunVersion: string; + target: "bun"; + minify: boolean; + sourcemap: "none"; + }; +}; + +export type ReleaseMetadata = { + schemaVersion: number; + name: string; + version: string; + tag: string; + toolchain: { + bunVersion: string; + }; + artifacts: ReleaseArtifact[]; +}; + +export type ToolchainContract = { + bunVersion: string; + packageManager: string; + enginesBun: string; +}; + +export const expectedReleaseTag = `v${VERSION}`; + +export async function readToolchainContract(): Promise { + const manifest = packageJson as PackageJson; + return { + bunVersion: (await Bun.file(bunVersionFile).text()).trim(), + packageManager: manifest.packageManager, + enginesBun: manifest.engines.bun, + }; +} + +export async function assertToolchain(actualBunVersion = Bun.version): Promise { + const contract = await readToolchainContract(); + const expectedPackageManager = `bun@${contract.bunVersion}`; + + if (contract.packageManager !== expectedPackageManager) { + throw new Error( + `packageManager must be ${expectedPackageManager}; received ${contract.packageManager}.`, + ); + } + if (contract.enginesBun !== SUPPORTED_BUN_RUNTIME_RANGE) { + throw new Error( + `engines.bun must preserve the supported runtime range ${SUPPORTED_BUN_RUNTIME_RANGE}; received ${contract.enginesBun}.`, + ); + } + if (actualBunVersion !== contract.bunVersion) { + throw new Error( + `Release builds require Bun ${contract.bunVersion}; received ${actualBunVersion}.`, + ); + } + if (packageJson.version !== VERSION) { + throw new Error( + `cli/package.json version (${packageJson.version}) must match cli/src/version.ts (${VERSION}).`, + ); + } + return contract; +} + +export function assertReleaseTag(tag: string): void { + if (tag !== expectedReleaseTag) { + throw new Error(`Release tag must be ${expectedReleaseTag}; received ${tag}.`); + } +} + +export function compileCommand(target: ReleaseTarget, outputPath: string): string[] { + return [ + process.execPath, + "build", + entrypoint, + ...HARDENED_COMPILE_FLAGS, + `--target=${target.bunTarget}`, + `--outfile=${outputPath}`, + ]; +} + +export function nativeReleaseTarget( + platform: NodeJS.Platform = process.platform, + architecture: string = process.arch, +): (typeof RELEASE_TARGETS)[number] { + const target = findReleaseTargetByPlatform(`${platform}-${architecture}`); + if (!target) { + throw new Error(`Unsupported native release platform: ${platform}-${architecture}.`); + } + return target; +} + +export async function compileReleaseTarget( + target: ReleaseTarget, + outputDirectory = defaultOutputDirectory, +): Promise { + await assertToolchain(); + await mkdir(outputDirectory, { recursive: true }); + const outputPath = join(outputDirectory, target.asset); + await run(compileCommand(target, outputPath)); + await assertNonemptyFile(outputPath); + return outputPath; +} + +export async function buildJavaScriptBundle( + outputDirectory = defaultOutputDirectory, +): Promise { + await assertToolchain(); + await mkdir(outputDirectory, { recursive: true }); + const outputPath = join(outputDirectory, RELEASE_BUNDLE_ASSET); + await buildNpmBundle(); + await copyFile(NPM_BUNDLE_PATH, outputPath); + await assertNonemptyFile(outputPath); + if ((await sha256(Bun.file(NPM_BUNDLE_PATH))) !== (await sha256(Bun.file(outputPath)))) { + throw new Error("GitHub and npm JavaScript release bundles differ."); + } + return outputPath; +} + +export async function buildAllReleaseArtifacts( + options: { + outputDirectory?: string; + tag?: string; + } = {}, +): Promise { + await assertToolchain(); + const outputDirectory = options.outputDirectory ?? defaultOutputDirectory; + const tag = options.tag ?? expectedReleaseTag; + assertReleaseTag(tag); + + for (const target of RELEASE_TARGETS) { + await compileReleaseTarget(target, outputDirectory); + } + await buildJavaScriptBundle(outputDirectory); + return writeReleaseMetadata(outputDirectory, tag); +} + +export async function writeReleaseMetadata( + outputDirectory = defaultOutputDirectory, + tag = expectedReleaseTag, +): Promise { + await assertToolchain(); + assertReleaseTag(tag); + + const nativeArtifacts = await Promise.all( + RELEASE_TARGETS.map(async (target): Promise => { + const file = Bun.file(join(outputDirectory, target.asset)); + if (!(await file.exists()) || file.size === 0) { + throw new Error(`Missing or empty release artifact: ${target.asset}.`); + } + return { + kind: "native", + file: target.asset, + bytes: file.size, + sha256: await sha256(file), + platform: target.platform, + bunTarget: target.bunTarget, + recipe: { + builder: "bun-build-executable", + bunVersion: Bun.version, + target: target.bunTarget, + flags: HARDENED_COMPILE_FLAGS, + }, + }; + }), + ); + + const bundle = Bun.file(join(outputDirectory, RELEASE_BUNDLE_ASSET)); + if (!(await bundle.exists()) || bundle.size === 0) { + throw new Error(`Missing or empty release artifact: ${RELEASE_BUNDLE_ASSET}.`); + } + + const metadata: ReleaseMetadata = { + schemaVersion: RELEASE_MANIFEST_SCHEMA_VERSION, + name: packageJson.name, + version: VERSION, + tag, + toolchain: { + bunVersion: Bun.version, + }, + artifacts: [ + ...nativeArtifacts, + { + kind: "javascript", + file: RELEASE_BUNDLE_ASSET, + bytes: bundle.size, + sha256: await sha256(bundle), + recipe: { + builder: "bun-build", + bunVersion: Bun.version, + ...NPM_BUNDLE_OPTIONS, + }, + }, + ], + }; + + const metadataPath = join(outputDirectory, RELEASE_METADATA_ASSET); + await writeAtomic(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); + + const checksumEntries = [ + ...metadata.artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })), + { + file: RELEASE_METADATA_ASSET, + sha256: await sha256(Bun.file(metadataPath)), + }, + ].sort((left, right) => left.file.localeCompare(right.file)); + await writeAtomic( + join(outputDirectory, RELEASE_CHECKSUMS_ASSET), + `${checksumEntries.map((entry) => `${entry.sha256} ${entry.file}`).join("\n")}\n`, + ); + + return metadata; +} + +async function assertNonemptyFile(path: string): Promise { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error(`Build did not produce a non-empty file: ${path}.`); + } +} + +async function writeAtomic(path: string, contents: string): Promise { + const temporaryPath = `${path}.${randomUUID()}.tmp`; + await Bun.write(temporaryPath, contents); + await rename(temporaryPath, path); +} + +async function sha256(file: Blob): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(await file.arrayBuffer()); + return hasher.digest("hex"); +} + +async function run(command: string[]): Promise { + const child = Bun.spawn(command, { + cwd: repositoryRoot, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + } +} + +async function runCapture(command: string[]): Promise { + const child = Bun.spawn(command, { + cwd: repositoryRoot, + stdout: "pipe", + stderr: "inherit", + }); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`Command failed (${exitCode}): ${command.join(" ")}`); + } + return output; +} + +export async function smokeReleaseTarget( + target: ReleaseTarget, + outputDirectory = defaultOutputDirectory, +): Promise { + const executable = join(outputDirectory, target.asset); + await assertNonemptyFile(executable); + const version = (await runCapture([executable, "--version"])).trim(); + if (version !== VERSION) { + throw new Error(`${target.asset} reported version ${version}; expected ${VERSION}.`); + } + const help = await runCapture([executable, "--help"]); + if (!help.includes("Altertable CLI") || !help.includes("Commands")) { + throw new Error(`${target.asset} returned incomplete help output.`); + } + console.log(`Smoke-tested ${target.asset} (${target.platform}).`); +} + +function optionValue(arguments_: string[], name: string): string | undefined { + return arguments_.find((argument) => argument.startsWith(`${name}=`))?.slice(`${name}=`.length); +} + +async function main(arguments_: string[]): Promise { + const command = arguments_[0]; + if (command === "matrix") { + console.log(JSON.stringify(releaseCiMatrix())); + return; + } + + if (command === "verify") { + await assertToolchain(); + const tag = optionValue(arguments_, "--tag"); + if (tag) { + assertReleaseTag(tag); + } + console.log(`Release contract verified for Bun ${Bun.version} and ${expectedReleaseTag}.`); + return; + } + + if (command === "finalize") { + const outputDirectory = optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory; + const metadata = await writeReleaseMetadata( + outputDirectory, + optionValue(arguments_, "--tag") ?? expectedReleaseTag, + ); + console.log(`Finalized ${metadata.artifacts.length} release artifacts.`); + return; + } + + if (command === "smoke") { + const requestedTarget = optionValue(arguments_, "--target"); + const target = requestedTarget ? findReleaseTargetByBunTarget(requestedTarget) : undefined; + if (!target) { + throw new Error( + requestedTarget + ? `Unsupported Bun release target: ${requestedTarget}.` + : "smoke requires --target=.", + ); + } + await smokeReleaseTarget( + target, + optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory, + ); + return; + } + + if (command !== "build") { + throw new Error( + "Usage: release.ts [--native|--bundle|--all|--target=]", + ); + } + + const outputDirectory = optionValue(arguments_, "--output-dir") ?? defaultOutputDirectory; + if (arguments_.includes("--all")) { + const metadata = await buildAllReleaseArtifacts({ + outputDirectory, + tag: optionValue(arguments_, "--tag"), + }); + console.log(`Built and finalized ${metadata.artifacts.length} release artifacts.`); + return; + } + + if (arguments_.includes("--bundle")) { + const outputPath = await buildJavaScriptBundle(outputDirectory); + console.log(`Built ${outputPath} (Bun JavaScript bundle).`); + return; + } + + const requestedTarget = optionValue(arguments_, "--target"); + const target = requestedTarget + ? findReleaseTargetByBunTarget(requestedTarget) + : arguments_.includes("--native") + ? nativeReleaseTarget() + : undefined; + if (!target) { + throw new Error( + requestedTarget + ? `Unsupported Bun release target: ${requestedTarget}.` + : "build requires --native, --bundle, --all, or --target=.", + ); + } + + const outputPath = await compileReleaseTarget(target, outputDirectory); + console.log(`Built ${outputPath} (${target.bunTarget}).`); +} + +if (import.meta.main) { + await main(Bun.argv.slice(2)); +} diff --git a/cli/scripts/smoke-npm-bundle.ts b/cli/scripts/smoke-npm-bundle.ts new file mode 100644 index 0000000..a134bc9 --- /dev/null +++ b/cli/scripts/smoke-npm-bundle.ts @@ -0,0 +1,53 @@ +import { join } from "node:path"; + +const repositoryRoot = join(import.meta.dir, "../.."); +const defaultBundlePath = join(repositoryRoot, "cli/dist/cli.js"); + +function optionValue(arguments_: string[], name: string): string | undefined { + return arguments_.find((argument) => argument.startsWith(`${name}=`))?.slice(`${name}=`.length); +} + +async function runBundle(bundlePath: string, arguments_: string[]): Promise { + const child = Bun.spawn([process.execPath, bundlePath, ...arguments_], { + cwd: repositoryRoot, + stdout: "pipe", + stderr: "inherit", + }); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`npm bundle exited with ${exitCode}: ${arguments_.join(" ")}`); + } + return output; +} + +export async function smokeNpmBundle(arguments_ = Bun.argv.slice(2)): Promise { + const expectedBun = optionValue(arguments_, "--expected-bun"); + if (expectedBun && Bun.version !== expectedBun) { + throw new Error(`Expected Bun ${expectedBun}; received ${Bun.version}.`); + } + + const bundlePath = optionValue(arguments_, "--bundle") ?? defaultBundlePath; + const version = (await runBundle(bundlePath, ["--version"])).trim(); + if (!/^\d+\.\d+\.\d+(?:-.+)?$/.test(version)) { + throw new Error(`npm bundle returned an invalid version: ${version}.`); + } + + const help = await runBundle(bundlePath, ["--help"]); + if (!help.includes("Altertable CLI") || !help.includes("Commands")) { + throw new Error("npm bundle help output is incomplete."); + } + + const specification = JSON.parse( + await runBundle(bundlePath, ["api", "spec", "--format=json"]), + ) as { openapi?: string }; + if (specification.openapi !== "3.1.0") { + throw new Error("npm bundle returned an unexpected OpenAPI document."); + } + + console.log(`Bun ${Bun.version}: npm bundle smoke test passed.`); +} + +if (import.meta.main) { + await smokeNpmBundle(); +} diff --git a/cli/src/lib/openapi-spec.ts b/cli/src/lib/openapi-spec.ts index b72c7e0..f420a7c 100644 --- a/cli/src/lib/openapi-spec.ts +++ b/cli/src/lib/openapi-spec.ts @@ -1,4 +1,5 @@ import openapiYaml from "../../openapi/openapi.yaml" with { type: "text" }; +import { parse } from "yaml"; const OPENAPI_SOURCE_HEADER_PATTERN = /^# AUTO-GENERATED[^\n]*\n(?:# [^\n]*\n)*/; @@ -14,7 +15,7 @@ export function getOpenapiSpecYaml(): string { } export function getOpenapiSpecJson(): string { - const document = Bun.YAML.parse(openapiYaml) as unknown; + const document = parse(openapiYaml) as unknown; return JSON.stringify(document, null, 2); } diff --git a/cli/src/lib/updater-config.ts b/cli/src/lib/updater-config.ts index 0b32a63..2906cc3 100644 --- a/cli/src/lib/updater-config.ts +++ b/cli/src/lib/updater-config.ts @@ -1,4 +1,9 @@ import { CLI_PACKAGE_METADATA } from "@/package-metadata.ts"; +import { + RELEASE_CHECKSUMS_ASSET, + RELEASE_PLATFORM_CONFIG, + type ReleasePlatform, +} from "@/release-manifest.ts"; import { objectKeys } from "@/lib/object.ts"; const DAILY_MS = 24 * 60 * 60 * 1000; @@ -40,13 +45,6 @@ const UpdaterInstallMethodConfig = { [UpdaterInstallMethod.githubBinary]: {}, } as const; -const UpdaterReleasePlatforms = { - "darwin-arm64": {}, - "darwin-x64": {}, - "linux-arm64": {}, - "linux-x64": {}, -} as const; - export const UpdaterInstallationKind = { nativeBinary: "native-binary", packageManager: "package-manager", @@ -84,10 +82,10 @@ export const UpdaterConfig = { automaticCheckSkipCommands: ["completion", "update"], installCommands: UpdaterInstallCommands, installMethods: UpdaterInstallMethodConfig, - releasePlatforms: UpdaterReleasePlatforms, + releasePlatforms: RELEASE_PLATFORM_CONFIG, installationKinds: UpdaterInstallationKind, binaryAssetPrefix: EXECUTABLE_NAME, - checksumsAssetName: "checksums.txt", + checksumsAssetName: RELEASE_CHECKSUMS_ASSET, installationDetection: { sourceScriptSuffixes: ["/src/cli.ts"], sourceScriptExtensions: [".ts"], @@ -107,7 +105,7 @@ export type UpdateCheckInterval = keyof typeof UpdaterConfig.intervalsMs; export type InstallManager = keyof typeof UpdaterConfig.installCommands; export type UpdateInstallMethod = keyof typeof UpdaterConfig.installMethods; export type ResolvedUpdateInstallMethod = Exclude; -export type ReleasePlatform = keyof typeof UpdaterConfig.releasePlatforms; +export type { ReleasePlatform }; export type InstallationKind = (typeof UpdaterConfig.installationKinds)[keyof typeof UpdaterConfig.installationKinds]; diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index 6450d8c..fb32236 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -528,7 +528,7 @@ export function detectReleasePlatform( } export function releaseAssetName(platform: ReleasePlatform): string { - return `${UpdaterConfig.binaryAssetPrefix}-${platform}`; + return UpdaterConfig.releasePlatforms[platform].asset; } function readGitHubAsset(data: unknown): { name: string; downloadUrl: string } | undefined { diff --git a/cli/src/release-manifest.ts b/cli/src/release-manifest.ts new file mode 100644 index 0000000..9dc8228 --- /dev/null +++ b/cli/src/release-manifest.ts @@ -0,0 +1,92 @@ +export type ReleaseOperatingSystem = "darwin" | "linux"; +export type ReleaseArchitecture = "arm64" | "x64"; + +export type ReleaseTarget = { + bunTarget: `bun-${string}`; + platform: `${ReleaseOperatingSystem}-${ReleaseArchitecture}`; + os: ReleaseOperatingSystem; + arch: ReleaseArchitecture; + asset: `altertable-${string}`; + runner: string; +}; + +/** + * Single source of truth for native release binaries. + * + * Asset names are a public updater contract. Keep them stable unless the updater + * supports both the old and new names during a migration. + */ +export const RELEASE_TARGETS = [ + { + bunTarget: "bun-darwin-arm64", + platform: "darwin-arm64", + os: "darwin", + arch: "arm64", + asset: "altertable-darwin-arm64", + runner: "macos-15", + }, + { + bunTarget: "bun-darwin-x64", + platform: "darwin-x64", + os: "darwin", + arch: "x64", + asset: "altertable-darwin-x64", + runner: "macos-15-intel", + }, + { + bunTarget: "bun-linux-arm64", + platform: "linux-arm64", + os: "linux", + arch: "arm64", + asset: "altertable-linux-arm64", + runner: "ubuntu-24.04-arm", + }, + { + bunTarget: "bun-linux-x64-baseline", + platform: "linux-x64", + os: "linux", + arch: "x64", + asset: "altertable-linux-x64", + runner: "ubuntu-24.04", + }, +] as const satisfies readonly ReleaseTarget[]; + +export type ReleasePlatform = (typeof RELEASE_TARGETS)[number]["platform"]; +export type ReleaseBunTarget = (typeof RELEASE_TARGETS)[number]["bunTarget"]; +export type ReleaseAssetName = (typeof RELEASE_TARGETS)[number]["asset"]; + +export const RELEASE_PLATFORM_CONFIG: Readonly< + Record +> = Object.freeze( + Object.fromEntries( + RELEASE_TARGETS.map((target) => [target.platform, { asset: target.asset }]), + ) as Record, +); + +export const RELEASE_BUNDLE_ASSET = "altertable-cli.js"; +export const RELEASE_CHECKSUMS_ASSET = "checksums.txt"; +export const RELEASE_METADATA_ASSET = "release-manifest.json"; + +export function findReleaseTargetByBunTarget( + bunTarget: string, +): (typeof RELEASE_TARGETS)[number] | undefined { + return RELEASE_TARGETS.find((target) => target.bunTarget === bunTarget); +} + +export function findReleaseTargetByPlatform( + platform: string, +): (typeof RELEASE_TARGETS)[number] | undefined { + return RELEASE_TARGETS.find((target) => target.platform === platform); +} + +export function releaseCiMatrix(): { + include: Array<{ target: ReleaseBunTarget; artifact: ReleaseAssetName; runner: string }>; +} { + return { + include: RELEASE_TARGETS.map((target) => ({ + target: target.bunTarget, + artifact: target.asset, + runner: target.runner, + })), + }; +} diff --git a/cli/tests/completion.test.ts b/cli/tests/completion.test.ts index 4343b69..e9fb361 100644 --- a/cli/tests/completion.test.ts +++ b/cli/tests/completion.test.ts @@ -68,6 +68,20 @@ function createMockPrompts(selection: string): ConfigurePrompts { }; } +async function captureCompletionOutput( + run: () => void | Promise, + json = false, +): Promise { + const output: string[] = []; + const runtime = createCliRuntime({ debug: false, json, agent: false }); + runtime.output.writeHuman = (text) => output.push(text); + runtime.output.writeJson = (data) => output.push(JSON.stringify(data)); + runtime.output.writeRaw = (body) => output.push(body); + + await runWithCliRuntime(runtime, run); + return output.join(""); +} + async function runCompletion( getRootCommand: () => CommandDef, shell?: string, @@ -83,20 +97,10 @@ async function runCompletion( throw new Error(`missing completion command for ${shell ?? "detected shell"}`); } - let output = ""; - const originalLog = console.log; const rawArgs = shell ? ["completion", shell] : ["completion"]; - console.log = (value?: unknown) => { - if (typeof value === "string") { - output += value; - } - }; - try { - await command.run({ args: command === completionCommand ? { shell } : {}, rawArgs } as never); - } finally { - console.log = originalLog; - } - return output; + return await captureCompletionOutput(() => + command.run?.({ args: command === completionCommand ? { shell } : {}, rawArgs } as never), + ); } async function runCompletionGenerate(shell: string): Promise { @@ -108,51 +112,32 @@ async function runCompletionGenerate(shell: string): Promise { throw new Error("missing completion generate command"); } - let output = ""; - const originalLog = console.log; - console.log = (value?: unknown) => { - if (typeof value === "string") { - output += value; - } - }; - try { - await command.run({ + return await captureCompletionOutput(() => + command.run?.({ args: {}, rawArgs: ["completion", "generate", shell], - } as never); - } finally { - console.log = originalLog; - } - return output; + } as never), + ); } -async function runCompletionParent(rawArgs: string[]): Promise { +async function runCompletionParent(rawArgs: string[], json = false): Promise { const completionCommand = createCompletionCommand(() => minimalRootCommand); if (!completionCommand.run) { throw new Error("missing completion command"); } - let output = ""; - const originalLog = console.log; - console.log = (value?: unknown) => { - if (typeof value === "string") { - output += value; - } - }; - try { - await completionCommand.run({ - args: {}, - rawArgs, - } as never); - } finally { - console.log = originalLog; - } - return output; + return await captureCompletionOutput( + () => + completionCommand.run?.({ + args: {}, + rawArgs, + } as never), + json, + ); } async function runCompletionParentJson(rawArgs: string[]): Promise { - const runtime = createCliRuntime({ debug: false, json: true, agent: false }); - return await runWithCliRuntime(runtime, () => runCompletionParent(rawArgs)); + return await runCompletionParent(rawArgs, true); } async function runCompletionInstall(shell?: string, noRc = false): Promise { @@ -167,26 +152,16 @@ async function runCompletionInstall(shell?: string, noRc = false): Promise { - if (typeof value === "string") { - output += value; - } - }; - try { - const rawArgs = shell ? ["completion", "install", shell] : ["completion", "install"]; - if (noRc) { - rawArgs.push("--no-rc"); - } - await command.run({ + const rawArgs = shell ? ["completion", "install", shell] : ["completion", "install"]; + if (noRc) { + rawArgs.push("--no-rc"); + } + return await captureCompletionOutput(() => + command.run?.({ args: shell === undefined ? { shell, "no-rc": noRc } : { "no-rc": noRc }, rawArgs, - } as never); - } finally { - console.log = originalLog; - } - return output; + } as never), + ); } async function runInteractiveCompletion(selection: string, shellPath?: string): Promise { diff --git a/cli/tests/release.test.ts b/cli/tests/release.test.ts new file mode 100644 index 0000000..81e34ea --- /dev/null +++ b/cli/tests/release.test.ts @@ -0,0 +1,489 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertReleaseTag, + assertToolchain, + buildJavaScriptBundle, + compileCommand, + expectedReleaseTag, + HARDENED_COMPILE_FLAGS, + JAVASCRIPT_BUNDLE_OPTIONS, + nativeReleaseTarget, + readToolchainContract, + RELEASE_MANIFEST_SCHEMA_VERSION, + SUPPORTED_BUN_RUNTIME_RANGE, + writeReleaseMetadata, +} from "@/../scripts/release.ts"; +import { NPM_BUNDLE_PATH } from "@/../scripts/npm-bundle.ts"; +import { + githubReleasePublishCommand, + githubReleaseUploadCommand, +} from "@/../scripts/github-release.ts"; +import { + assertTrustedPublishingEnvironment, + MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION, + npmPublicationRequired, + parseNpmVersionLookup, +} from "@/../scripts/publish-npm.ts"; +import { UpdaterConfig } from "@/lib/updater-config.ts"; +import { releaseAssetName } from "@/lib/updater.ts"; +import { + RELEASE_BUNDLE_ASSET, + RELEASE_CHECKSUMS_ASSET, + RELEASE_METADATA_ASSET, + RELEASE_TARGETS, + releaseCiMatrix, +} from "@/release-manifest.ts"; +import { VERSION } from "@/version.ts"; + +const temporaryDirectories: string[] = []; +const repositoryRoot = join(import.meta.dir, "../.."); + +type WorkflowStep = { + env?: Record; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; +type WorkflowJob = { + if?: string; + name?: string; + needs?: string | string[]; + permissions?: Record; + steps?: WorkflowStep[]; + uses?: string; + with?: Record; +}; +type Workflow = { jobs: Record }; + +async function readWorkflow(name: string): Promise { + return Bun.YAML.parse( + await readFile(join(repositoryRoot, ".github/workflows", name), "utf8"), + ) as Workflow; +} + +function workflowNeeds(job: WorkflowJob): string[] { + if (!job.needs) return []; + return Array.isArray(job.needs) ? job.needs : [job.needs]; +} + +function workflowStepIndex(job: WorkflowJob, name: string): number { + return job.steps?.findIndex((step) => step.name === name) ?? -1; +} + +async function fileSha256(path: string): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(await Bun.file(path).arrayBuffer()); + return hasher.digest("hex"); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("release target manifest", () => { + test("defines unique updater-compatible targets and artifacts", () => { + expect(RELEASE_TARGETS).toHaveLength(4); + expect(new Set(RELEASE_TARGETS.map((target) => target.bunTarget)).size).toBe( + RELEASE_TARGETS.length, + ); + expect(new Set(RELEASE_TARGETS.map((target) => target.platform)).size).toBe( + RELEASE_TARGETS.length, + ); + expect(new Set(RELEASE_TARGETS.map((target) => target.asset)).size).toBe( + RELEASE_TARGETS.length, + ); + expect(RELEASE_TARGETS.find((target) => target.platform === "linux-x64")?.bunTarget).toBe( + "bun-linux-x64-baseline", + ); + expect(Object.keys(UpdaterConfig.releasePlatforms).sort()).toEqual( + RELEASE_TARGETS.map((target) => target.platform).sort(), + ); + for (const target of RELEASE_TARGETS) { + expect(releaseAssetName(target.platform)).toBe(target.asset); + } + expect(RELEASE_TARGETS.map((target) => target.asset).sort()).toEqual([ + "altertable-darwin-arm64", + "altertable-darwin-x64", + "altertable-linux-arm64", + "altertable-linux-x64", + ]); + }); + + test("generates the complete GitHub Actions matrix", () => { + expect(releaseCiMatrix()).toEqual({ + include: RELEASE_TARGETS.map((target) => ({ + target: target.bunTarget, + artifact: target.asset, + runner: target.runner, + })), + }); + }); + + test("uses supported runner images for every release target", () => { + expect(RELEASE_TARGETS.find(({ platform }) => platform === "darwin-arm64")?.runner).toBe( + "macos-15", + ); + expect(RELEASE_TARGETS.map(({ runner }) => runner)).not.toContain("macos-14"); + }); + + test("detects native targets without changing public asset names", () => { + expect(nativeReleaseTarget("darwin", "arm64").asset).toBe("altertable-darwin-arm64"); + expect(nativeReleaseTarget("linux", "x64").asset).toBe("altertable-linux-x64"); + expect(() => nativeReleaseTarget("win32", "x64")).toThrow( + "Unsupported native release platform: win32-x64.", + ); + }); +}); + +describe("release toolchain contract", () => { + test("pins the build toolchain without raising the package runtime floor", async () => { + const contract = await readToolchainContract(); + + expect(contract.bunVersion).toBe(Bun.version); + expect(contract.packageManager).toBe(`bun@${contract.bunVersion}`); + expect(contract.enginesBun).toBe(SUPPORTED_BUN_RUNTIME_RANGE); + expect(contract.enginesBun).not.toBe(`>=${contract.bunVersion}`); + expect(await assertToolchain()).toEqual(contract); + let error: unknown; + try { + await assertToolchain("0.0.0"); + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `Release builds require Bun ${contract.bunVersion}; received 0.0.0.`, + ); + }); + + test("requires release tags to match the CLI version", () => { + expect(expectedReleaseTag).toBe(`v${VERSION}`); + expect(() => assertReleaseTag(expectedReleaseTag)).not.toThrow(); + expect(() => assertReleaseTag("v0.0.0")).toThrow( + `Release tag must be ${expectedReleaseTag}; received v0.0.0.`, + ); + }); + + test("uses hardened standalone compiler flags", () => { + const target = RELEASE_TARGETS[0]; + const command = compileCommand(target, "/tmp/altertable-test-binary"); + + expect(command).toContain("--compile"); + expect(command).toContain("--minify"); + expect(command).toContain("--bytecode"); + expect(command).toContain("--reject-unresolved"); + expect(command).toContain("--no-compile-autoload-dotenv"); + expect(command).toContain("--no-compile-autoload-bunfig"); + expect(command).toContain(`--target=${target.bunTarget}`); + expect(command).toContain("--outfile=/tmp/altertable-test-binary"); + expect(new Set(HARDENED_COMPILE_FLAGS).size).toBe(HARDENED_COMPILE_FLAGS.length); + }); +}); + +describe("release metadata", () => { + test("writes a complete, checksummed release inventory atomically", async () => { + const directory = await mkdtemp(join(tmpdir(), "altertable-release-test-")); + temporaryDirectories.push(directory); + for (const target of RELEASE_TARGETS) { + await Bun.write(join(directory, target.asset), target.bunTarget); + } + await Bun.write(join(directory, RELEASE_BUNDLE_ASSET), "console.log('altertable');\n"); + + const metadata = await writeReleaseMetadata(directory); + const onDisk = JSON.parse( + await readFile(join(directory, RELEASE_METADATA_ASSET), "utf8"), + ) as typeof metadata; + const checksums = await readFile(join(directory, RELEASE_CHECKSUMS_ASSET), "utf8"); + const checksumEntries = new Map( + checksums + .trim() + .split("\n") + .map((line) => { + const [digest, file] = line.split(/\s+/, 2); + if (!digest || !file) throw new Error(`Invalid checksum line: ${line}`); + return [file, digest] as const; + }), + ); + + expect(metadata.schemaVersion).toBe(RELEASE_MANIFEST_SCHEMA_VERSION); + expect(metadata.version).toBe(VERSION); + expect(metadata.tag).toBe(expectedReleaseTag); + expect(metadata.toolchain.bunVersion).toBe(Bun.version); + expect(metadata.artifacts).toHaveLength(RELEASE_TARGETS.length + 1); + expect(onDisk).toEqual(metadata); + for (const artifact of metadata.artifacts) { + expect(artifact.bytes).toBeGreaterThan(0); + expect(artifact.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(checksumEntries.get(artifact.file)).toBe( + await fileSha256(join(directory, artifact.file)), + ); + } + for (const target of RELEASE_TARGETS) { + const artifact = metadata.artifacts.find(({ file }) => file === target.asset); + expect(artifact?.recipe).toEqual({ + builder: "bun-build-executable", + bunVersion: Bun.version, + target: target.bunTarget, + flags: HARDENED_COMPILE_FLAGS, + }); + } + expect(metadata.artifacts.find(({ kind }) => kind === "javascript")?.recipe).toEqual({ + builder: "bun-build", + bunVersion: Bun.version, + ...JAVASCRIPT_BUNDLE_OPTIONS, + }); + expect(checksumEntries.get(RELEASE_METADATA_ASSET)).toBe( + await fileSha256(join(directory, RELEASE_METADATA_ASSET)), + ); + expect([...checksumEntries.keys()].sort((left, right) => left.localeCompare(right))).toEqual( + [...metadata.artifacts.map(({ file }) => file), RELEASE_METADATA_ASSET].sort((left, right) => + left.localeCompare(right), + ), + ); + }); + + test("stages the exact npm bundle as the GitHub JavaScript asset", async () => { + const directory = await mkdtemp(join(tmpdir(), "altertable-release-bundle-test-")); + temporaryDirectories.push(directory); + + const releaseBundle = await buildJavaScriptBundle(directory); + + expect(await fileSha256(releaseBundle)).toBe(await fileSha256(NPM_BUNDLE_PATH)); + }); +}); + +describe("retry-safe npm publication", () => { + test("distinguishes published versions, missing versions, and registry failures", () => { + expect(parseNpmVersionLookup(0, '"1.1.0"\n', "")).toEqual({ + status: "published", + version: "1.1.0", + }); + expect(parseNpmVersionLookup(1, "", "npm error code E404")).toEqual({ status: "missing" }); + expect(() => parseNpmVersionLookup(1, "", "network timeout")).toThrow( + "npm view failed (1): network timeout", + ); + expect(npmPublicationRequired({ status: "missing" }, "1.1.0")).toBe(true); + expect(npmPublicationRequired({ status: "published", version: "1.1.0" }, "1.1.0")).toBe(false); + expect(() => + npmPublicationRequired({ status: "published", version: "1.0.0" }, "1.1.0"), + ).toThrow("npm returned version 1.0.0; expected 1.1.0"); + }); + + test("requires tokenless GitHub OIDC and a compatible npm CLI before publishing", () => { + const oidcEnvironment = { + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "ephemeral-token", + ACTIONS_ID_TOKEN_REQUEST_URL: "https://actions.example.test/oidc", + }; + + expect(() => + assertTrustedPublishingEnvironment(oidcEnvironment, MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION), + ).not.toThrow(); + expect(() => assertTrustedPublishingEnvironment(oidcEnvironment, "12.0.0")).not.toThrow(); + expect(() => assertTrustedPublishingEnvironment(oidcEnvironment, "11.5.0")).toThrow( + `npm trusted publishing requires npm >=${MINIMUM_TRUSTED_PUBLISHING_NPM_VERSION}`, + ); + expect(() => assertTrustedPublishingEnvironment({}, "12.0.0")).toThrow( + "npm trusted publishing requires GitHub Actions OIDC credentials", + ); + expect(() => + assertTrustedPublishingEnvironment( + { ...oidcEnvironment, NODE_AUTH_TOKEN: "secret" }, + "12.0.0", + ), + ).toThrow("npm publishing must use OIDC"); + }); +}); + +describe("GitHub release publication", () => { + test("uploads the complete manifest-owned asset set before publishing the draft", () => { + const upload = githubReleaseUploadCommand("v1.1.0"); + const publish = githubReleasePublishCommand("v1.1.0"); + + expect(upload.slice(0, 4)).toEqual(["gh", "release", "upload", "v1.1.0"]); + for (const target of RELEASE_TARGETS) expect(upload.join("\n")).toContain(target.asset); + for (const asset of [RELEASE_BUNDLE_ASSET, RELEASE_METADATA_ASSET, RELEASE_CHECKSUMS_ASSET]) { + expect(upload.join("\n")).toContain(asset); + } + expect(upload.at(-1)).toBe("--clobber"); + expect(publish).toEqual(["gh", "release", "edit", "v1.1.0", "--draft=false", "--latest"]); + }); +}); + +describe("release infrastructure wiring", () => { + test("keeps target literals out of build and workflow orchestration", async () => { + const files = [ + "Makefile", + ".github/workflows/test.yml", + ".github/workflows/release-please.yml", + ]; + for (const file of files) { + const contents = await readFile(join(repositoryRoot, file), "utf8"); + for (const target of RELEASE_TARGETS) { + expect(contents).not.toContain(target.bunTarget); + } + } + }); + + test("enforces hardened workflow conventions", async () => { + const workflows = [ + "codeql.yml", + "dependency-review.yml", + "release-please.yml", + "semantic-pull-request.yml", + "test.yml", + "verify.yml", + ]; + for (const workflow of workflows) { + const contents = await readFile(join(repositoryRoot, ".github/workflows", workflow), "utf8"); + expect(contents).toContain("permissions:"); + expect(contents).toContain("timeout-minutes:"); + expect(contents).not.toContain("runs-on: ubuntu-latest"); + expect(contents).not.toContain("continue-on-error: true"); + + const actionReferences = [...contents.matchAll(/uses:\s+[^@\s]+@([^\s]+)/g)]; + for (const reference of actionReferences) { + expect(reference[1]).toMatch(/^[0-9a-f]{40}$/); + } + if (contents.includes("actions/checkout@")) { + expect(contents).toContain("persist-credentials: false"); + } + } + }); + + test("gates ordered draft publication on canonical and native verification", async () => { + const workflow = await readWorkflow("release-please.yml"); + const releasePleaseConfig = JSON.parse( + await readFile(join(repositoryRoot, "release-please-config.json"), "utf8"), + ) as { packages: Record }; + const verification = workflow.jobs["verify-release"] ?? {}; + const matrix = workflow.jobs["release-matrix"] ?? {}; + const native = workflow.jobs["release-native"] ?? {}; + const publication = workflow.jobs["release-artifacts"] ?? {}; + + expect(releasePleaseConfig.packages["."]?.draft).toBe(true); + expect(verification.uses).toBe("./.github/workflows/verify.yml"); + expect(workflowNeeds(verification)).toEqual(["release-please"]); + expect(verification.with?.ref).toContain("tag_name"); + expect(workflowNeeds(matrix)).toEqual(["release-please", "verify-release"]); + expect(workflowNeeds(native)).toEqual(["release-please", "verify-release", "release-matrix"]); + expect(workflowNeeds(publication)).toEqual(["release-please", "release-native"]); + + const orderedSteps = [ + "Download tested release binaries", + "Build JavaScript release bundle", + "Finalize exact release artifacts", + "Smoke test npm bundle on release toolchain", + "Install minimum supported npm runtime", + "Smoke test exact npm bundle on minimum runtime", + "Restore release build toolchain", + "Verify release checksums", + "Attest release provenance", + "Upload release assets", + "Publish npm package", + "Publish completed GitHub release", + ].map((name) => workflowStepIndex(publication, name)); + expect(orderedSteps.every((index) => index >= 0)).toBe(true); + expect(orderedSteps).toEqual([...orderedSteps].sort((left, right) => left - right)); + expect(publication.steps?.[orderedSteps[10] ?? -1]?.run).toBe("bun run release:publish-npm"); + expect(publication.steps?.[orderedSteps[9] ?? -1]?.run).toBe("bun run release:upload-github"); + expect(publication.steps?.[orderedSteps[11] ?? -1]?.run).toBe("bun run release:publish-github"); + }); + + test("retains recoverable release binaries longer than diagnostic artifacts", async () => { + const releaseWorkflow = await readWorkflow("release-please.yml"); + const verificationWorkflow = await readWorkflow("verify.yml"); + const branchWorkflow = await readWorkflow("test.yml"); + + const releaseUpload = releaseWorkflow.jobs["release-native"]?.steps?.find( + ({ name }) => name === "Upload tested release binary", + ); + const coverageUpload = verificationWorkflow.jobs.repository?.steps?.find( + ({ name }) => name === "Upload coverage report", + ); + const branchUpload = branchWorkflow.jobs.compile?.steps?.find( + ({ name }) => name === "Upload compiled binary", + ); + + expect(releaseUpload?.with?.["retention-days"]).toBe(30); + expect(coverageUpload?.with?.["retention-days"]).toBe(7); + expect(branchUpload?.with?.["retention-days"]).toBe(7); + }); + + test("publishes npm through trusted publishing without a long-lived token", async () => { + const workflow = await readWorkflow("release-please.yml"); + const publication = workflow.jobs["release-artifacts"] ?? {}; + const publishStep = publication.steps?.find(({ name }) => name === "Publish npm package"); + + expect(publication.permissions?.["id-token"]).toBe("write"); + expect(publishStep?.run).toBe("bun run release:publish-npm"); + expect(publishStep?.env?.NODE_AUTH_TOKEN).toBeUndefined(); + expect(publishStep?.env?.NPM_TOKEN).toBeUndefined(); + expect( + await readFile(join(repositoryRoot, ".github/workflows/release-please.yml"), "utf8"), + ).not.toContain("NPM_TOKEN"); + }); + + test("pins the integration image and waits for its TCP listener", async () => { + const workflow = await readFile(join(repositoryRoot, ".github/workflows/verify.yml"), "utf8"); + + expect(workflow).toContain( + "ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977", + ); + expect(workflow).toContain("/dev/tcp/127.0.0.1/15000"); + expect(workflow).not.toContain("altertable-mock:latest"); + expect(workflow).not.toContain('--health-cmd "exit 0"'); + }); + + test("keeps the Bun 1.1 runtime path free of Bun's newer YAML API", async () => { + const packageJson = JSON.parse( + await readFile(join(repositoryRoot, "cli/package.json"), "utf8"), + ) as { dependencies: Record; engines: { bun: string } }; + const openapiSpec = await readFile(join(repositoryRoot, "cli/src/lib/openapi-spec.ts"), "utf8"); + + expect(packageJson.engines.bun).toBe(SUPPORTED_BUN_RUNTIME_RANGE); + expect(packageJson.dependencies.yaml).toBe("2.9.0"); + expect(openapiSpec).not.toContain("Bun.YAML"); + + const verification = await readWorkflow("verify.yml"); + const runtimeJob = verification.jobs["npm-runtime"] ?? {}; + expect( + runtimeJob.steps?.find(({ name }) => name === "Install minimum supported runtime"), + ).toEqual( + expect.objectContaining({ uses: expect.stringMatching(/^oven-sh\/setup-bun@[0-9a-f]{40}$/) }), + ); + expect( + runtimeJob.steps?.find(({ name }) => name === "Smoke test npm bundle on minimum runtime") + ?.run, + ).toBe("bun run cli/scripts/smoke-npm-bundle.ts --expected-bun=1.1.0"); + }); + + test("routes branch CI through the same canonical verification workflow", async () => { + const workflow = await readWorkflow("test.yml"); + const verification = workflow.jobs.verify ?? {}; + const required = workflow.jobs.required ?? {}; + + expect(verification.uses).toBe("./.github/workflows/verify.yml"); + expect(verification.with?.ref).toContain("github.sha"); + expect(workflowNeeds(workflow.jobs["release-matrix"] ?? {})).toContain("verify"); + expect(workflowNeeds(workflow.jobs.compile ?? {})).toContain("verify"); + expect(required.name).toBe("Required"); + expect(required.if).toBe("always()"); + expect(workflowNeeds(required)).toEqual(["verify", "release-matrix", "compile"]); + + const enforcement = required.steps?.find(({ name }) => name === "Enforce required CI results"); + expect(enforcement?.env).toEqual({ + VERIFY_RESULT: "${{ needs.verify.result }}", + RELEASE_MATRIX_RESULT: "${{ needs.release-matrix.result }}", + COMPILE_RESULT: "${{ needs.compile.result }}", + }); + expect(enforcement?.run).toContain('exit "$failed"'); + }); +}); diff --git a/release-please-config.json b/release-please-config.json index d818638..4f4d822 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,6 +5,7 @@ "release-type": "node", "package-name": "@altertable/cli", "component": "altertable-cli", + "draft": true, "extra-files": [ { "type": "json", diff --git a/scripts/verify.sh b/scripts/verify.sh index e833001..b26b1af 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -38,7 +38,7 @@ check_mock_server() { echo "✗ integration requires mock server at http://0.0.0.0:15000" >&2 echo " Start with: docker run -d --rm --name at-mock -p 15000:15000 \\" >&2 echo " -e ALTERTABLE_MOCK_USERS=testuser:testpass \\" >&2 - echo " ghcr.io/altertable-ai/altertable-mock:latest" >&2 + echo " ghcr.io/altertable-ai/altertable-mock@sha256:2e85cecd30b582a28196fc7574b2c7ae323378ccf40abfe658e2692270799977" >&2 exit 1 fi } @@ -67,9 +67,7 @@ cd "${REPO_ROOT}/cli" run_step "build" bun run build run_step "pack:check" bun run pack:check -chmod +x "${REPO_ROOT}/bin/altertable" -run_step "altertable --version" "${REPO_ROOT}/bin/altertable" --version -run_step "altertable --help" "${REPO_ROOT}/bin/altertable" --help +run_step "npm bundle smoke test" bun run scripts/smoke-npm-bundle.ts if [[ "${RUN_INTEGRATION}" == true ]]; then check_mock_server