From 1ea40ccd62aa85d322590171b598228da8767859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 13 Jul 2026 16:22:41 +0200 Subject: [PATCH 1/3] feat(release): centralize and harden artifact publishing Define one typed release manifest for platform targets, public artifact names, updater resolution, and CI runners. Move build, smoke, upload, and publication behavior into directly testable repository scripts instead of embedding programs in workflow YAML. Gate branch and release publication on one reusable canonical verification workflow. Build and execute native artifacts on supported target runners, test the exact npm bytes under the release and minimum Bun runtimes, emit artifact-specific recipes and verified checksums, and attest the same bytes that are published. Keep GitHub releases draft until npm succeeds, make npm publication idempotent for retries, retain exact release binaries for the full 30-day rerun window, and preserve the independently tested Bun >=1.1.0 runtime contract. Replace long-lived npm publish credentials with GitHub Actions OIDC trusted publishing. Fail closed when OIDC or the required npm CLI is unavailable, reject token fallback, and document the staged post-release token revocation process. --- .bun-version | 2 +- .github/workflows/codeql.yml | 18 +- .github/workflows/dependency-review.yml | 16 +- .github/workflows/release-please.yml | 211 +++++++-- .github/workflows/semantic-pull-request.yml | 14 +- .github/workflows/test.yml | 162 +++---- .github/workflows/verify.yml | 87 ++++ DEVELOPMENT.md | 63 ++- Makefile | 43 +- README.md | 1 + cli/bun.lock | 1 + cli/package.json | 15 +- cli/scripts/github-release.ts | 55 +++ cli/scripts/npm-bundle.ts | 34 ++ cli/scripts/publish-npm.ts | 117 +++++ cli/scripts/release.ts | 425 ++++++++++++++++++ cli/scripts/smoke-npm-bundle.ts | 53 +++ cli/src/lib/openapi-spec.ts | 3 +- cli/src/lib/updater-config.ts | 18 +- cli/src/lib/updater.ts | 2 +- cli/src/release-manifest.ts | 92 ++++ cli/tests/release.test.ts | 475 ++++++++++++++++++++ release-please-config.json | 1 + scripts/verify.sh | 6 +- 24 files changed, 1687 insertions(+), 227 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 cli/scripts/github-release.ts create mode 100644 cli/scripts/npm-bundle.ts create mode 100644 cli/scripts/publish-npm.ts create mode 100644 cli/scripts/release.ts create mode 100644 cli/scripts/smoke-npm-bundle.ts create mode 100644 cli/src/release-manifest.ts create mode 100644 cli/tests/release.test.ts 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..1a97982 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,133 +6,83 @@ 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 - if-no-files-found: error - - - name: Build JS bundle - run: bun run build - working-directory: cli - - - 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 - 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 }} + if-no-files-found: error + retention-days: 7 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 f23bd15..64caa3f 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", @@ -96,10 +94,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"], @@ -119,7 +117,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 bf5643e..094b48a 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -551,7 +551,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/release.test.ts b/cli/tests/release.test.ts new file mode 100644 index 0000000..373bbd7 --- /dev/null +++ b/cli/tests/release.test.ts @@ -0,0 +1,475 @@ +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 = { + 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 ?? {}; + + 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"); + }); +}); 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 From d9f1ca0ff01fb29acd53159e190d5f821abbf8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 15 Jul 2026 11:01:01 +0200 Subject: [PATCH 2/3] test(cli): isolate completion output capture Capture completion output through an explicit per-helper CLI runtime instead of intercepting console.log. This keeps the tests aligned with the OutputSink contract and prevents suite ordering or shared runtime state from turning valid output into empty strings. --- cli/tests/completion.test.ts | 103 +++++++++++++---------------------- 1 file changed, 39 insertions(+), 64 deletions(-) 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 { From 43858f86a2824a54718c9a7f3e5ff59adb0f5fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 15 Jul 2026 11:22:30 +0200 Subject: [PATCH 3/3] ci: add stable required test gate Aggregate canonical verification, release-matrix resolution, and every native compile matrix leg behind one durable Required check. Run the gate with always() so failed, cancelled, or skipped prerequisites cannot produce a false success, and protect the workflow contract with semantic tests. --- .github/workflows/test.yml | 30 ++++++++++++++++++++++++++++++ cli/tests/release.test.ts | 14 ++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a97982..2561d83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,3 +86,33 @@ jobs: path: dist/${{ matrix.artifact }} if-no-files-found: error retention-days: 7 + + required: + name: Required + needs: [verify, release-matrix, compile] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + + steps: + - name: Enforce required CI results + env: + VERIFY_RESULT: ${{ needs.verify.result }} + RELEASE_MATRIX_RESULT: ${{ needs.release-matrix.result }} + COMPILE_RESULT: ${{ needs.compile.result }} + run: | + 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/cli/tests/release.test.ts b/cli/tests/release.test.ts index 373bbd7..81e34ea 100644 --- a/cli/tests/release.test.ts +++ b/cli/tests/release.test.ts @@ -49,6 +49,8 @@ type WorkflowStep = { with?: Record; }; type WorkflowJob = { + if?: string; + name?: string; needs?: string | string[]; permissions?: Record; steps?: WorkflowStep[]; @@ -466,10 +468,22 @@ describe("release infrastructure wiring", () => { 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"'); }); });