Skip to content

Release

Release #68

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
type:
description: 'Release type'
required: true
type: choice
options:
- pre-release
- release
version:
description: 'Version bump'
required: false
type: choice
default: patch
options:
- patch
- minor
- major
skip-windows-e2e:
description: 'Skip Windows E2E tests (they take a long time)'
required: false
type: boolean
default: false
skip-macos-e2e:
description: 'Skip macOS E2E tests (they take a long time)'
required: false
type: boolean
default: false
permissions:
contents: write
id-token: write
concurrency:
group: release
cancel-in-progress: false
jobs:
e2e-windows:
if: ${{ !inputs.skip-windows-e2e }}
uses: ./.github/workflows/e2e-windows.yml
e2e-macos:
if: ${{ !inputs.skip-macos-e2e }}
uses: ./.github/workflows/e2e-macos.yml
e2e-linux:
# Re-run the full Linux E2E matrix on the exact commit being released.
# ci.yml only runs on push/PR, so a manually-dispatched release (especially
# a pre-release from a feature branch) is not otherwise guaranteed to have
# had Linux E2E executed against this SHA.
uses: ./.github/workflows/e2e-linux.yml
conformance:
# Gate releases on MCP spec conformance so we never publish a version
# that regresses against the reference conformance suite.
uses: ./.github/workflows/conformance.yml
release:
name: Publish ${{ inputs.type }}
runs-on: ubuntu-latest
needs: [e2e-windows, e2e-macos, e2e-linux, conformance]
# Release must be from main; pre-release can be from any branch.
# Allow the e2e-windows and e2e-macos dependencies to be either successful
# or skipped (when the user opts out via skip-windows-e2e / skip-macos-e2e).
# Linux E2E and conformance must pass.
if: |
always() &&
(needs.e2e-windows.result == 'success' || needs.e2e-windows.result == 'skipped') &&
(needs.e2e-macos.result == 'success' || needs.e2e-macos.result == 'skipped') &&
needs.e2e-linux.result == 'success' &&
needs.conformance.result == 'success' &&
(inputs.type == 'pre-release' || github.ref == 'refs/heads/main')
steps:
- name: Validate inputs
run: |
if [[ "${{ inputs.type }}" == "release" && "${{ github.ref }}" != "refs/heads/main" ]]; then
echo "::error::Releases must be published from the main branch."
exit 1
fi
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Use a PAT so the release job can push the version-bump
# commit and tag directly to main, bypassing branch-protection
# rulesets that block the default GITHUB_TOKEN.
token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24
registry-url: https://registry.npmjs.org
- name: Install pnpm and dependencies
uses: apify/actions/pnpm-install@v1.1.2
- name: Check dependency age
# Supply-chain gate: pnpm's `minimumReleaseAge` only applies when resolving new
# versions, and pnpm 10 does not re-check an existing lockfile on a
# --frozen-lockfile install, so without this nothing enforces the quarantine at
# release time. Runs before the version bump, tag, push and publish steps, so a
# failure costs nothing and leaves no partial release behind.
run: pnpm run check:deps-age
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Unit tests
run: pnpm run test:unit
- name: Update README
if: inputs.type == 'release'
run: pnpm run build:readme
- name: Update REFERENCE.md
if: inputs.type == 'release'
run: pnpm run build:reference
- name: Report install size
id: install-size
# Measures the tarball/unpacked/full-install size of what will be
# published and compares it with the latest version on npm: prints a
# table to the logs and run summary, warns if this release is much
# bigger, and exposes the report as an output that the GitHub release
# steps embed in the release body.
run: node scripts/report-install-size.mjs
# ── Pre-release ────────────────────────────────────────────────
- name: Bump pre-release version
if: inputs.type == 'pre-release'
id: prerelease
run: |
PKG_NAME=$(node -p "require('./package.json').name")
CURRENT=$(node -p "require('./package.json').version")
BUMP="${{ inputs.version }}"
# Compute the next base version according to the selected bump type
# e.g. 0.1.10 + minor -> 0.2.0, 0.1.10 + major -> 1.0.0, 0.1.10 + patch -> 0.1.11
# If already a beta, strip the pre-release suffix first
NEXT_BASE=$(node -p "
const base = '${CURRENT}'.replace(/-beta\..*/, '');
const v = base.split('.').map(Number);
if ('${BUMP}' === 'major') { v[0]++; v[1] = 0; v[2] = 0; }
else if ('${BUMP}' === 'minor') { v[1]++; v[2] = 0; }
else { v[2]++; }
v.join('.')
")
# If current version is already a beta for this base, don't re-bump
CURRENT_BASE=$(node -p "'${CURRENT}'.replace(/-beta\..*/, '')")
if [[ "${CURRENT}" == *"-beta."* ]]; then
# Already on a beta — check if it matches the target base
# e.g. current=0.2.0-beta.1, bump=minor from 0.1.10 -> NEXT_BASE=0.2.0 -> matches, keep it
# e.g. current=0.1.11-beta.1, bump=minor from 0.1.10 -> NEXT_BASE=0.2.0 -> mismatch, use NEXT_BASE
if [[ "$CURRENT_BASE" == "$NEXT_BASE" ]]; then
NEXT_BASE="$CURRENT_BASE"
fi
fi
echo "Base version for beta: $NEXT_BASE"
# Query npm for existing betas of this base version
LATEST_BETA=$(npm view "${PKG_NAME}" versions --json 2>/dev/null \
| node -p "
const all = JSON.parse(require('fs').readFileSync(0,'utf8'));
const betas = (Array.isArray(all) ? all : [all])
.filter(v => v.startsWith('${NEXT_BASE}-beta.'))
.sort((a,b) => {
const na = Number(a.split('-beta.')[1]);
const nb = Number(b.split('-beta.')[1]);
return na - nb;
});
betas.length ? betas[betas.length-1] : ''
" \
) || true
if [[ -n "$LATEST_BETA" && "$LATEST_BETA" == ${NEXT_BASE}-beta.* ]]; then
# Existing beta found — increment from it
echo "Found existing beta on npm: $LATEST_BETA — incrementing"
pnpm version "$LATEST_BETA" --no-git-tag-version --allow-same-version
pnpm version prerelease --preid=beta --no-git-tag-version
else
# No beta yet — start at beta.0
echo "No existing beta for ${NEXT_BASE} — creating ${NEXT_BASE}-beta.0"
pnpm version "${NEXT_BASE}-beta.0" --no-git-tag-version --allow-same-version
fi
NEW_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "package-name=$PKG_NAME" >> "$GITHUB_OUTPUT"
# Print install instructions to the job logs AND the run summary so
# they are visible both while the run is in progress and on the run
# summary tab afterwards.
{
echo "### Pre-release version: \`$NEW_VERSION\`"
echo ""
echo "#### Install"
echo "\`\`\`bash"
echo "npm install -g ${PKG_NAME}@${NEW_VERSION}"
echo "# or"
echo "bun install -g ${PKG_NAME}@${NEW_VERSION}"
echo "\`\`\`"
} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Publish pre-release to npm
if: inputs.type == 'pre-release'
run: MCPC_RELEASE=1 pnpm publish --access public --tag beta --provenance --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub pre-release
if: inputs.type == 'pre-release'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.prerelease.outputs.version }}
name: v${{ steps.prerelease.outputs.version }}
prerelease: true
generate_release_notes: true
# Append install instructions to the auto-generated "What's Changed"
# so the release page shows users how to install this version.
append_body: true
body: |
## Install
```bash
npm install -g ${{ steps.prerelease.outputs.package-name }}@${{ steps.prerelease.outputs.version }}
# or
bun install -g ${{ steps.prerelease.outputs.package-name }}@${{ steps.prerelease.outputs.version }}
```
${{ steps.install-size.outputs.report }}
target_commitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
# ── Release ────────────────────────────────────────────────────
- name: Bump release version
if: inputs.type == 'release'
id: release
run: |
CURRENT=$(node -p "require('./package.json').version")
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
pnpm version "${{ inputs.version }}" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "package-name=$PKG_NAME" >> "$GITHUB_OUTPUT"
# Print install instructions to the job logs AND the run summary so
# they are visible both while the run is in progress and on the run
# summary tab afterwards.
{
echo "### Release version: \`$NEW_VERSION\`"
echo ""
echo "#### Install"
echo "\`\`\`bash"
echo "npm install -g ${PKG_NAME}@${NEW_VERSION}"
echo "# or"
echo "bun install -g ${PKG_NAME}@${NEW_VERSION}"
echo "\`\`\`"
} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Update CHANGELOG.md
if: inputs.type == 'release'
run: |
NEW_VERSION="${{ steps.release.outputs.version }}"
CURRENT="${{ steps.release.outputs.current }}"
TODAY=$(date +%Y-%m-%d)
# Replace [Unreleased] heading with new version, keep an empty [Unreleased] section
sed -i "s/^## \[Unreleased\]/## [Unreleased]\n\n## [$NEW_VERSION] - $TODAY/" CHANGELOG.md
# Update comparison links at the bottom
if grep -q "^\[Unreleased\]:" CHANGELOG.md; then
sed -i "s|\[Unreleased\]: \(.*\)/compare/v[0-9.]*\.\.\.HEAD|[Unreleased]: \1/compare/v$NEW_VERSION...HEAD\n[$NEW_VERSION]: \1/compare/v$CURRENT...v$NEW_VERSION|" CHANGELOG.md
fi
- name: Commit version bump and changelog
if: inputs.type == 'release'
run: |
NEW_VERSION="${{ steps.release.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json pnpm-lock.yaml CHANGELOG.md README.md docs/REFERENCE.md docs/images/related-work.svg
git commit -m "v${NEW_VERSION}"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin main
git push origin "v${NEW_VERSION}"
- name: Publish release to npm
if: inputs.type == 'release'
run: MCPC_RELEASE=1 pnpm publish --access public --provenance --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub release
if: inputs.type == 'release'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.release.outputs.version }}
name: v${{ steps.release.outputs.version }}
generate_release_notes: true
# Append install instructions to the auto-generated "What's Changed"
# so the release page shows users how to install this version.
append_body: true
body: |
## Install
```bash
npm install -g ${{ steps.release.outputs.package-name }}@${{ steps.release.outputs.version }}
# or
bun install -g ${{ steps.release.outputs.package-name }}@${{ steps.release.outputs.version }}
```
${{ steps.install-size.outputs.report }}
env:
GITHUB_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
# ── Homebrew tap ───────────────────────────────────────────────
- name: Homebrew formula update instructions
if: inputs.type == 'release'
# The formula bump in apify/homebrew-tap is started by hand for now, so
# this step only prints the command — it deliberately does not dispatch
# anything. To automate it later, run the printed command in this step
# (with GH_TOKEN: secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN) and keep it
# last and continue-on-error, so a Homebrew hiccup can never fail a
# release that is already tagged, published to npm and announced.
#
# The dispatched workflow points Formula/mcpc.rb at the new npm tarball,
# installs and `brew test`s it on Linux and macOS, then merges the bump.
# It polls the registry itself, so it tolerates publish propagation.
continue-on-error: true
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
{
echo "### Homebrew"
echo ""
echo "The formula bump is manual for now. To ship \`${VERSION}\` to \`brew\`, run:"
echo ""
echo '```bash'
echo "gh workflow run update_formula.yaml --repo apify/homebrew-tap \\"
echo " --field package=mcpc --field npm_package=@apify/mcpc --field version=${VERSION}"
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"