name: release-please run-name: promptfoo release by @${{ github.actor }} on: push: branches: - main workflow_dispatch: inputs: tag_name: description: 'Optional existing release tag to publish to npm (e.g., 0.121.10)' required: false type: string permissions: contents: read concurrency: group: release-please-${{ github.ref }} cancel-in-progress: false jobs: release-please: if: github.event_name != 'workflow_dispatch' || inputs.tag_name == '' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} code_scan_action_release_created: ${{ steps.release.outputs['code-scan-action--release_created'] }} code_scan_action_tag_name: ${{ steps.release.outputs['code-scan-action--tag_name'] }} code_scan_action_version: ${{ steps.release.outputs['code-scan-action--version'] }} steps: # Use GitHub App token so the created PR triggers CI workflows # (PRs created with GITHUB_TOKEN don't trigger workflows to prevent loops). # Token inherits the App installation's granted permissions. Previous # attempt to narrow via `permission-*` inputs failed with 422 because # the PROMPTFOOBOT installation does not grant all of them (release-please # PR #8796 landed green, then main broke on the next release-please run). # If we want to narrow here, first widen the App installation permissions # in the GitHub App settings, then re-add the `permission-*` inputs. - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token with: app-id: ${{ vars.PROMPTFOOBOT_APP_ID }} private-key: ${{ secrets.PROMPTFOOBOT_APP_PRIVATE_KEY }} - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: release with: token: ${{ steps.app-token.outputs.token }} build: # release-please can create a release, then still fail while opening the # next release PR. Preserve artifact publication when the release outputs # prove a release was already created. if: >- ${{ always() && !cancelled() && ( needs.release-please.outputs.release_created == 'true' || needs.release-please.outputs.code_scan_action_release_created == 'true' ) }} runs-on: ubuntu-latest timeout-minutes: 10 needs: release-please permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' cache: 'npm' # Node 24 ships with npm 11.6.2 which has a lockfile compatibility bug (npm/cli#8669) - run: npm install -g npm@11.11.0 - run: npm ci - run: npm test publish-npm: if: >- ${{ always() && !cancelled() && needs.release-please.outputs.release_created == 'true' && needs.build.result == 'success' }} needs: [build, release-please] runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read id-token: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' cache: 'npm' registry-url: 'https://registry.npmjs.org' # Node 24 ships with npm 11.6.2 which has a lockfile compatibility bug (npm/cli#8669) - run: npm install -g npm@11.11.0 - run: npm ci # Empty NODE_AUTH_TOKEN so npm uses OIDC trusted publishing instead of the # placeholder token actions/setup-node writes into .npmrc (actions/setup-node#1440). - run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: '' PROMPTFOO_POSTHOG_KEY: ${{ secrets.PROMPTFOO_POSTHOG_KEY }} publish-npm-backfill: # npm trusted publishing is bound to this workflow filename on npmjs.com, # so manual backfills must stay inside release-please.yml. if: github.event_name == 'workflow_dispatch' && inputs.tag_name != '' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read id-token: write env: TAG_NAME: ${{ inputs.tag_name }} steps: - name: Validate release tag run: | if [[ ! "$TAG_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]]; then echo "::error::TAG_NAME '$TAG_NAME' is empty or invalid" exit 1 fi - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: refs/tags/${{ inputs.tag_name }} - name: Verify package version matches tag run: | package_version="$(node -p "require('./package.json').version")" if [[ "$package_version" != "$TAG_NAME" ]]; then echo "::error::package.json version '$package_version' does not match tag '$TAG_NAME'" exit 1 fi - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' cache: 'npm' registry-url: 'https://registry.npmjs.org' # Node 24 ships with npm 11.6.2 which has a lockfile compatibility bug (npm/cli#8669) - run: npm install -g npm@11.11.0 - run: npm ci - name: Publish package run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: '' PROMPTFOO_POSTHOG_KEY: ${{ secrets.PROMPTFOO_POSTHOG_KEY }} docker: if: >- ${{ always() && !cancelled() && needs.publish-npm.result == 'success' && needs.release-please.outputs.release_created == 'true' }} needs: [publish-npm, release-please] permissions: contents: read packages: write id-token: write attestations: write uses: ./.github/workflows/docker.yml with: tag_name: ${{ needs.release-please.outputs.tag_name }} publish-code-scan-action: if: >- ${{ always() && !cancelled() && needs.release-please.outputs.code_scan_action_release_created == 'true' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 15 # Gate on `build` so root test failures block the mirror — code-scan-action # imports shared source from ../../src, so broken root code means broken action. # `publish-npm` is in `needs` for ordering only, not gating — see the # npm-availability check below for the rationale. needs: [release-please, build, publish-npm] permissions: contents: read env: # Plain release-artifact files mirrored into promptfoo/code-scan-action. # `dist/` (a directory) and `.release-source.json` (generated below, not # copied from source) are handled separately in each step. MIRROR_ARTIFACT_FILES: |- action.yml README.md CHANGELOG.md steps: # Token is scoped to the foreign repo (code-scan-action) via owner/ # repositories. Do NOT add `permission-*` inputs here until the App # installation is confirmed to grant every requested permission — see # the matching note on the release-please job above. - name: Create app token for code-scan-action mirror uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: mirror-token with: app-id: ${{ vars.PROMPTFOOBOT_APP_ID }} private-key: ${{ secrets.PROMPTFOOBOT_APP_PRIVATE_KEY }} owner: promptfoo repositories: code-scan-action - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: '.nvmrc' cache: 'npm' # Node 24 ships with npm 11.6.2 which has a lockfile compatibility bug (npm/cli#8669) - run: npm install -g npm@11.11.0 # The action's runtime install is pinned to this commit's root package.json # version, inlined into dist/ at build time. Refuse to mirror a release whose # pinned scanner version never reached npm (e.g. a failed publish-npm awaiting # backfill) — otherwise every consumer scan fails at install time with ETARGET # (no matching version found). # The registry is the ground truth (a publish-npm job result would wrongly # block a version that was backfilled later); `needs: publish-npm` guarantees # a same-run publish has finished before this check runs, and the retries # absorb registry read-after-write lag for a just-published version. - name: Verify pinned promptfoo version is published to npm run: | set -euo pipefail version="$(node -p "require('./package.json').version")" for attempt in 1 2 3 4 5; do if npm view "promptfoo@${version}" version; then exit 0 fi if [[ "$attempt" -lt 5 ]]; then echo "promptfoo@${version} not visible on npm (attempt ${attempt}/5); retrying in 30s" sleep 30 fi done echo "::error::promptfoo@${version} is not published to npm; the mirrored action would fail at install time" exit 1 # Install root deps first; code-scan-action imports shared source from ../../src - run: npm ci - name: Install Code Scan Action dependencies working-directory: code-scan-action run: npm ci - name: Type Check Code Scan Action working-directory: code-scan-action run: npm run tsc - name: Build Code Scan Action working-directory: code-scan-action run: npm run build # Companion to the npm-availability gate above: that step validates the version # read from package.json, this one asserts the built bundle actually embeds the # same pin — so a future refactor of the pin source in main.ts cannot desync # the gate from what ships. - name: Verify built dist embeds the published scanner pin run: | set -euo pipefail version="$(node -p "require('./package.json').version")" count="$(grep -Fc "\"${version}\"" code-scan-action/dist/index.js || true)" echo "dist/index.js embeds \"${version}\" ${count} time(s)" if [[ "$count" -eq 0 ]]; then echo "::error::dist/index.js does not embed promptfoo@${version}; the runtime pin has desynced from the npm-availability gate" exit 1 fi - name: Prepare mirror release payload env: CODE_SCAN_ACTION_VERSION: ${{ needs.release-please.outputs.code_scan_action_version }} SOURCE_SHA: ${{ github.sha }} SOURCE_TAG: ${{ needs.release-please.outputs.code_scan_action_tag_name }} run: | set -euo pipefail export_dir="$RUNNER_TEMP/code-scan-action-export" rm -rf "$export_dir" mkdir -p "$export_dir" mapfile -t mirror_artifact_files <<< "$MIRROR_ARTIFACT_FILES" for file in "${mirror_artifact_files[@]}"; do cp "code-scan-action/$file" "$export_dir/$file" done cp -R code-scan-action/dist "$export_dir/dist" cat > "$export_dir/.release-source.json" <