diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fab625a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# CI, publishing, dependencies, and runner image changes need maintainer review. +/.github/ @biw +/docker/ @biw +/scripts/prepare.mjs @biw +/package.json @biw +/pnpm-lock.yaml @biw +/skills-lock.json @biw diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 844bc45..bbdaf7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,89 +3,15 @@ name: CI on: push: branches: [main] - # Use the workflow from protected `main`, so a pull request cannot remove - # the approval gate before it is allowed to use paid Cloudflare compute. - pull_request_target: + pull_request: branches: [main] permissions: - actions: read contents: read jobs: - select-cloudflare-ci-environment: - name: Select Cloudflare CI approval - runs-on: ubuntu-latest - outputs: - name: ${{ steps.select.outputs.name }} - requires_approval: ${{ steps.select.outputs.requires_approval }} - steps: - - id: select - env: - EVENT_NAME: ${{ github.event_name }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - if [ "$EVENT_NAME" != "pull_request_target" ]; then - echo 'name=cloudflare-ci' >> "$GITHUB_OUTPUT" - echo 'requires_approval=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - - permission="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - "/repos/$GITHUB_REPOSITORY/collaborators/$PR_AUTHOR/permission" \ - --jq '.permission' 2>/dev/null || true)" - - if [ "$permission" = 'admin' ]; then - echo 'name=cloudflare-ci' >> "$GITHUB_OUTPUT" - echo 'requires_approval=false' >> "$GITHUB_OUTPUT" - else - # Fail closed: an API error or a non-admin author is reviewed by a - # repository admin through the protected environment. - echo 'name=cloudflare-ci-approval' >> "$GITHUB_OUTPUT" - echo 'requires_approval=true' >> "$GITHUB_OUTPUT" - fi - - approve-cloudflare-ci: - name: Approve Cloudflare CI - needs: select-cloudflare-ci-environment - runs-on: ubuntu-latest - environment: - name: ${{ needs.select-cloudflare-ci-environment.outputs.name }} - # This is a CI admission gate, not a deployment record. - deployment: false - steps: - - name: Verify the approval protection rule - if: needs.select-cloudflare-ci-environment.outputs.requires_approval == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - reviewer_count="$(gh api \ - -H 'Accept: application/vnd.github+json' \ - "/repos/$GITHUB_REPOSITORY/environments/cloudflare-ci-approval" \ - --jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers[]?] | length')" - - if [ "$reviewer_count" -eq 0 ]; then - echo 'Cloudflare CI is blocked: cloudflare-ci-approval must have a repository administrator configured as a required reviewer.' >&2 - exit 1 - fi - - - env: - REQUIRES_APPROVAL: ${{ needs.select-cloudflare-ci-environment.outputs.requires_approval }} - run: | - if [ "$REQUIRES_APPROVAL" = 'true' ]; then - echo 'A repository administrator approved this pull request for Cloudflare CI.' >> "$GITHUB_STEP_SUMMARY" - else - echo 'The pull-request author is a repository administrator; Cloudflare CI is authorized.' >> "$GITHUB_STEP_SUMMARY" - fi - ci: name: CI (${{ matrix.runner_index }}) - needs: approve-cloudflare-ci strategy: fail-fast: false max-parallel: 2 @@ -94,32 +20,35 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - # `pull_request_target` uses this trusted workflow from `main`. Check - # out the pull-request merge ref only after the approval gate above. - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: - ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} - - name: Enable pnpm - run: corepack enable + node-version: "26" + package-manager-cache: false + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6 - name: Resolve pnpm store id: pnpm-store run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" - name: Restore pnpm cache id: pnpm-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ steps.pnpm-store.outputs.path }} - key: pnpm-linux-x64-node-22-${{ hashFiles('pnpm-lock.yaml') }} + key: pnpm-linux-x64-node-26-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | - pnpm-linux-x64-node-22- + pnpm-linux-x64-node-26- - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline - name: Save pnpm cache if: matrix.runner_index == 1 && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ steps.pnpm-store.outputs.path }} - key: pnpm-linux-x64-node-22-${{ hashFiles('pnpm-lock.yaml') }} + key: pnpm-linux-x64-node-26-${{ hashFiles('pnpm-lock.yaml') }} - name: Check run: pnpm run check - name: Test @@ -129,14 +58,12 @@ jobs: runner-image-size: name: Runner image under 1.5 GB - needs: approve-cloudflare-ci runs-on: ubuntu-latest timeout-minutes: 30 steps: - # Match the revision exercised by the parallel CI jobs. - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} + persist-credentials: false - name: Build and check runner image size run: ./tests/runner-image-size.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e9a003e..ac2d886 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,154 +6,16 @@ on: permissions: contents: read -concurrency: - group: npm-publish - cancel-in-progress: false - jobs: check-version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.check.outputs.version }} - should_publish: ${{ steps.check.outputs.should_publish }} - - steps: - - name: Checkout the CI-tested revision - uses: actions/checkout@v7 - with: - fetch-depth: 0 - ref: ${{ github.sha }} - - - name: Verify the tested revision is still main - id: revision - env: - TESTED_SHA: ${{ github.sha }} - run: | - if [ "$(git rev-parse HEAD)" != "$TESTED_SHA" ]; then - echo "Checked out revision does not match the successful CI run" >&2 - exit 1 - fi - - git fetch --no-tags origin main - - if [ "$(git rev-parse origin/main)" != "$TESTED_SHA" ]; then - echo "is_current=false" >> "$GITHUB_OUTPUT" - echo "Skipping stale successful CI revision $TESTED_SHA" - exit 0 - fi - - echo "is_current=true" >> "$GITHUB_OUTPUT" - - - name: Setup Node.js - if: steps.revision.outputs.is_current == 'true' - uses: actions/setup-node@v7 - with: - node-version: "24" - - - name: Check if version should be published - if: steps.revision.outputs.is_current == 'true' - id: check - run: | - CURRENT_VERSION=$(node -p "require('./package.json').version") - PACKAGE_NAME=$(node -p "require('./package.json').name") - - echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - echo "Current package: $PACKAGE_NAME@$CURRENT_VERSION" - - if npm view "$PACKAGE_NAME" versions --json > published-versions.json 2> npm-view-error.log; then - : - elif grep --quiet 'E404' npm-view-error.log; then - printf '[]\n' > published-versions.json - else - cat npm-view-error.log >&2 - exit 1 - fi - - if node -e " - const versions = require('./published-versions.json') - const list = Array.isArray(versions) ? versions : [versions] - process.exit(list.includes(process.argv[1]) ? 0 : 1) - " "$CURRENT_VERSION"; then - echo "Version $CURRENT_VERSION is already published" - echo "should_publish=false" >> "$GITHUB_OUTPUT" - else - echo "Version $CURRENT_VERSION has not been published" - echo "should_publish=true" >> "$GITHUB_OUTPUT" - fi + uses: biw/npm-trusted-publish-workflows/.github/workflows/check.yml@v1 publish: needs: check-version if: needs.check-version.outputs.should_publish == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - - steps: - - name: Checkout the CI-tested revision - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - package-manager-cache: false - - - name: Enable pnpm - run: corepack enable - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Verify package - run: pnpm run prepublishOnly && npm pack --dry-run - - name: Verify npm supports trusted publishing - run: | - NPM_VERSION=$(npm --version) - echo "npm version: $NPM_VERSION" - - node -e " - const version = process.argv[1].split('.').map(Number) - const minimum = [11, 5, 1] - - for (let i = 0; i < minimum.length; i++) { - if (version[i] > minimum[i]) process.exit(0) - if (version[i] < minimum[i]) process.exit(1) - } - " "$NPM_VERSION" - - name: Reconfirm the published revision - env: - TESTED_SHA: ${{ github.sha }} - run: | - git fetch --no-tags origin main - - if [ "$(git rev-parse HEAD)" != "$TESTED_SHA" ] || [ "$(git rev-parse origin/main)" != "$TESTED_SHA" ]; then - echo "Refusing to publish a revision that is no longer the tip of main" >&2 - exit 1 - fi - - name: Publish to npm - run: npm publish - - release: - needs: - - check-version - - publish - if: >- - needs.check-version.result == 'success' && - needs.check-version.outputs.should_publish == 'true' && - needs.publish.result == 'success' - runs-on: ubuntu-latest + uses: biw/npm-trusted-publish-workflows/.github/workflows/publish.yml@v1 + with: + tested-sha: ${{ github.sha }} permissions: contents: write - - steps: - - name: Create GitHub Release - uses: softprops/action-gh-release@v3 - with: - tag_name: v${{ needs.check-version.outputs.version }} - name: v${{ needs.check-version.outputs.version }} - target_commitish: ${{ github.sha }} - draft: false - prerelease: false - generate_release_notes: true + id-token: write diff --git a/package.json b/package.json index 646e52a..d159329 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "deploy:dry-run": "vp exec tsx scripts/deploy.ts --dry-run", "resource-traces": "vp exec tsx scripts/resource-metrics.ts", "cf-typegen": "vp exec wrangler types", - "prepare": "pnpx skills experimental_install", + "prepare": "node scripts/prepare.mjs", "prepack": "vp pack", "prepublishOnly": "vp check && vp test" }, diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 0000000..f3d224a --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,17 @@ +import { spawnSync } from "node:child_process"; + +if (process.env.CI || process.env.GITHUB_ACTIONS) { + console.log("Skipping agent skill installation in CI."); + process.exit(0); +} + +const result = spawnSync("pnpx", ["skills", "experimental_install"], { + stdio: "inherit", + shell: process.platform === "win32", +}); + +if (result.error) { + throw result.error; +} + +process.exitCode = result.status ?? 1; diff --git a/tests/prepare.test.ts b/tests/prepare.test.ts new file mode 100644 index 0000000..2cbd99b --- /dev/null +++ b/tests/prepare.test.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +const prepareScript = fileURLToPath(new URL("../scripts/prepare.mjs", import.meta.url)); +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function runPrepare(ci: string, githubActions: string, installerExitCode = 0) { + const directory = await mkdtemp(join(tmpdir(), "runner-prepare-test-")); + directories.push(directory); + const fixture = join(directory, "installer.cjs"); + await writeFile( + fixture, + "console.log(JSON.stringify(process.argv.slice(2)));\nprocess.exit(Number(process.env.SKILLS_TEST_EXIT_CODE));\n", + ); + await writeFile( + join(directory, process.platform === "win32" ? "pnpx.cmd" : "pnpx"), + process.platform === "win32" + ? '@"%SKILLS_TEST_NODE_PATH%" "%SKILLS_TEST_FIXTURE_PATH%" %*\r\n' + : '#!/bin/sh\nexec "$SKILLS_TEST_NODE_PATH" "$SKILLS_TEST_FIXTURE_PATH" "$@"\n', + { mode: 0o755 }, + ); + + return spawnSync(process.execPath, [prepareScript], { + env: { + ...process.env, + CI: ci, + GITHUB_ACTIONS: githubActions, + PATH: directory, + SKILLS_TEST_EXIT_CODE: String(installerExitCode), + SKILLS_TEST_NODE_PATH: process.execPath, + SKILLS_TEST_FIXTURE_PATH: fixture, + }, + encoding: "utf8", + }); +} + +describe("package preparation", () => { + it.each([ + ["true", ""], + ["1", ""], + ["", "true"], + ])("skips the external installer with CI=%s and GITHUB_ACTIONS=%s", async (ci, githubActions) => { + const result = await runPrepare(ci, githubActions, 17); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("Skipping agent skill installation in CI.\n"); + expect(result.stderr).toBe(""); + }); + + it("preserves local installation and forwards the installer arguments", async () => { + const result = await runPrepare("", ""); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('["skills","experimental_install"]\n'); + expect(result.stderr).toBe(""); + }); + + it("propagates a failed local installation", async () => { + const result = await runPrepare("", "", 17); + + expect(result.status).toBe(17); + expect(result.stdout).toBe('["skills","experimental_install"]\n'); + }); +});