diff --git a/.github/workflows/changesets-pr.yml b/.github/workflows/changesets-pr.yml index 01c303a95c..ef8d1483e5 100644 --- a/.github/workflows/changesets-pr.yml +++ b/.github/workflows/changesets-pr.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release/**" paths: - "packages/**" - ".changeset/**" @@ -56,16 +57,21 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number') + # On main: PR branch is `changeset-release/main`. + # On release/X.Y.x: PR branch is `changeset-release/release/X.Y.x`. + # Same source-branch lookup either way. + SOURCE_BRANCH="${GITHUB_REF_NAME}" + PR_BRANCH="changeset-release/${SOURCE_BRANCH}" + PR_NUMBER=$(gh pr list --head "$PR_BRANCH" --json number --jq '.[0].number') if [ -n "$PR_NUMBER" ]; then - git fetch origin changeset-release/main + git fetch origin "+${PR_BRANCH}:refs/remotes/origin/${PR_BRANCH}" # we arbitrarily reference the version of the cli package here; it is the same for all package releases - VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version') + VERSION=$(git show "origin/${PR_BRANCH}:packages/cli-v3/package.json" | jq -r '.version') gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION" # Enhance the PR body with a clean, deduplicated summary RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body') - ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION") + ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" SOURCE_BRANCH="$SOURCE_BRANCH" node scripts/enhance-release-pr.mjs "$VERSION") if [ -n "$ENHANCED_BODY" ]; then gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \ -X PATCH \ diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index b4ac9defb6..61c75e54db 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -13,6 +13,11 @@ on: type: string required: false default: "" + is_latest: + description: "Whether this version should also tag :v4-beta. release.yml computes via version comparison." + type: boolean + required: false + default: false secrets: SENTRY_AUTH_TOKEN: required: false @@ -52,16 +57,31 @@ jobs: ref_without_tag=ghcr.io/triggerdotdev/trigger.dev image_tags=$ref_without_tag:${STEPS_GET_TAG_OUTPUTS_TAG} - # if tag is a semver, also tag it as v4 if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then - # TODO: switch to v4 tag on GA - image_tags=$image_tags,$ref_without_tag:v4-beta + # Strip leading 'v' to get bare version (vX.Y.Z -> X.Y.Z) + VERSION="${STEPS_GET_TAG_OUTPUTS_TAG#v}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + + # Per-line floating tag — analog of npm "release-X.Y" dist-tag. + # Always set on a semver build so line-pinned consumers always get + # the latest patch on their line, regardless of whether it's the + # global :v4-beta / :latest. + image_tags=$image_tags,$ref_without_tag:release-${MAJOR_MINOR} + + # Global :v4-beta updated only when release.yml decided this version + # is the highest-ever. Hotfixes on lagged lines must NOT clobber it. + # TODO: switch to :latest on v4 GA. + if [[ "${INPUTS_IS_LATEST}" == true ]]; then + image_tags=$image_tags,$ref_without_tag:v4-beta + fi fi echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + echo "::notice::Docker tags: ${image_tags}" env: STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} + INPUTS_IS_LATEST: ${{ inputs.is_latest }} - name: 📝 Set the build info id: set_build_info diff --git a/.github/workflows/publish-worker-v4.yml b/.github/workflows/publish-worker-v4.yml index c3b72c6b7d..c715f2f8e7 100644 --- a/.github/workflows/publish-worker-v4.yml +++ b/.github/workflows/publish-worker-v4.yml @@ -8,6 +8,11 @@ on: type: string required: false default: "" + is_latest: + description: "Whether this version should also tag :v4-beta. release.yml computes via version comparison." + type: boolean + required: false + default: false push: tags: - "re2-test-*" @@ -68,17 +73,28 @@ jobs: ref_without_tag=ghcr.io/triggerdotdev/${STEPS_GET_REPOSITORY_OUTPUTS_REPO} image_tags=$ref_without_tag:${STEPS_GET_TAG_OUTPUTS_TAG} - # if tag is a semver, also tag it as v4 if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then - # TODO: switch to v4 tag on GA - image_tags=$image_tags,$ref_without_tag:v4-beta + VERSION="${STEPS_GET_TAG_OUTPUTS_TAG#v}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + + # Per-line floating tag — line-pinned consumers always get the latest + # patch on their line, regardless of whether it's the global :v4-beta. + image_tags=$image_tags,$ref_without_tag:release-${MAJOR_MINOR} + + # Global :v4-beta updated only when this is the highest-ever version. + # TODO: switch to :latest on v4 GA. + if [[ "${INPUTS_IS_LATEST}" == true ]]; then + image_tags=$image_tags,$ref_without_tag:v4-beta + fi fi echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + echo "::notice::Docker tags: ${image_tags}" env: STEPS_GET_REPOSITORY_OUTPUTS_REPO: ${{ steps.get_repository.outputs.repo }} STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} + INPUTS_IS_LATEST: ${{ inputs.is_latest }} - name: 🐙 Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0bc873d80d..9669fd754d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,11 @@ on: description: The image tag to publish required: true type: string + is_latest: + description: "Whether this version is the highest released version. Drives :v4-beta / :latest tag updates. release.yml computes this via version comparison." + required: false + type: boolean + default: false secrets: DOCKERHUB_USERNAME: required: false @@ -73,6 +78,7 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: image_tag: ${{ inputs.image_tag }} + is_latest: ${{ inputs.is_latest }} publish-worker: needs: [typecheck] @@ -95,3 +101,4 @@ jobs: uses: ./.github/workflows/publish-worker-v4.yml with: image_tag: ${{ inputs.image_tag }} + is_latest: ${{ inputs.is_latest }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f0c8cae30..5fad03bec8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: types: [closed] branches: - main + - "release/**" workflow_dispatch: inputs: type: @@ -38,7 +39,7 @@ jobs: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'pull_request' && github.event.pull_request.merged == true && - github.event.pull_request.head.ref == 'changeset-release/main' + startsWith(github.event.pull_request.head.ref, 'changeset-release/') steps: - name: Show release summary env: @@ -58,12 +59,13 @@ jobs: github.repository == 'triggerdotdev/trigger.dev' && ( (github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') || - (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main') + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'changeset-release/')) ) outputs: published: ${{ steps.changesets.outputs.published }} published_packages: ${{ steps.changesets.outputs.publishedPackages }} published_package_version: ${{ steps.get_version.outputs.package_version }} + is_latest: ${{ steps.compare.outputs.is_latest }} steps: - name: Checkout repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] needs persisted git creds for tag push; no artifact upload here so no leak path @@ -71,15 +73,25 @@ jobs: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} - - name: Verify ref is on main + - name: Verify ref is on main or a release branch if: github.event_name == 'workflow_dispatch' - run: | - if ! git merge-base --is-ancestor "${GITHUB_EVENT_INPUTS_REF}" origin/main; then - echo "Error: ref must be an ancestor of main (i.e., already merged)" - exit 1 - fi env: GITHUB_EVENT_INPUTS_REF: ${{ github.event.inputs.ref }} + run: | + set -e + if git merge-base --is-ancestor "${GITHUB_EVENT_INPUTS_REF}" origin/main; then + echo "Ref is reachable from main." + exit 0 + fi + # Look for any origin/release/* branch that contains this ref. + for branch in $(git branch -r --list 'origin/release/*' --format '%(refname:short)'); do + if git merge-base --is-ancestor "${GITHUB_EVENT_INPUTS_REF}" "${branch}"; then + echo "Ref is reachable from ${branch}." + exit 0 + fi + done + echo "Error: ref must be reachable from main or a release/* branch." >&2 + exit 1 - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -109,11 +121,62 @@ jobs: - name: Type check run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev" + # Decide whether this publish should become the new "latest" everywhere + # (npm dist-tag, Docker `:v4-beta` / `:latest`, GitHub release "Latest" + # badge, marketing-site changelog). + # + # Right rule: compare the new version to the current `latest`. NEW > CURRENT + # => becomes latest. Otherwise => goes to a per-line dist-tag (release-X.Y). + # + # Handles two scenarios with one rule: + # - Lagged hotfix (main shipped 4.6.0, hotfix 4.4.7 from release/4.4.x): + # 4.4.7 < 4.6.0 -> NOT latest. dist-tag release-4.4. + # - Fresh hotfix while main has unreleased work (main released 4.4.5, + # PR #3173 merged but unreleased; hotfix 4.4.6 from release/4.4.x): + # 4.4.6 > 4.4.5 -> IS latest. Customers running `npm install` get it. + # + # Source of truth: npm's `latest` dist-tag for the canonical package + # (`@trigger.dev/sdk`). All public packages are version-locked via the + # `fixed` config, so any one is canonical. + - name: Compare new version to current latest + id: compare + run: | + set -euo pipefail + NEW=$(jq -r '.version' packages/cli-v3/package.json) + CURRENT=$(npm view @trigger.dev/sdk dist-tags.latest 2>/dev/null || true) + if [ -z "$CURRENT" ] || [ "$CURRENT" = "undefined" ]; then + CURRENT="0.0.0" + fi + + # sort -V is semver-aware. NEW strictly greater than CURRENT => becomes latest. + HIGHER=$(printf '%s\n%s\n' "$NEW" "$CURRENT" | sort -V | tail -1) + if [ "$HIGHER" = "$NEW" ] && [ "$NEW" != "$CURRENT" ]; then + IS_LATEST=true + DIST_TAG="" + else + IS_LATEST=false + DIST_TAG="release-$(echo "$NEW" | cut -d. -f1,2)" + fi + + { + echo "new=${NEW}" + echo "current=${CURRENT}" + echo "is_latest=${IS_LATEST}" + echo "dist_tag=${DIST_TAG}" + } >> "$GITHUB_OUTPUT" + + echo "::notice::Publishing ${NEW}; current latest=${CURRENT}; is_latest=${IS_LATEST}; dist_tag=${DIST_TAG:-latest}" + - name: Publish id: changesets uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1.7.0 with: - publish: pnpm run changeset:release + # When this publish lags (lower than current latest), pass --tag release- + # so npm's `latest` dist-tag is not touched. Otherwise default (= latest). + # `release-` prefix because npm rejects dist-tag names that parse as a valid + # semver range (`v4.4`, `4.4`, `4.4.x` would all be rejected). + # Build was done above; this step only publishes. + publish: ${{ steps.compare.outputs.dist_tag != '' && format('pnpm exec changeset publish --tag {0}', steps.compare.outputs.dist_tag) || 'pnpm exec changeset publish' }} createGithubReleases: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -133,13 +196,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_PR_BODY: ${{ github.event.pull_request.body }} STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} + IS_LATEST: ${{ steps.compare.outputs.is_latest }} run: | VERSION="${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md + # --target dropped: changesets created the per-package tags on the + # release commit (which lives on main OR a release/* branch); the + # tag itself carries the right commit, no need to pin --target. + # --latest set explicitly: GitHub auto-detect uses publish date, + # which would mark a lagged hotfix as "Latest" by accident. gh release create "v${VERSION}" \ --title "trigger.dev v${VERSION}" \ --notes-file /tmp/release-body.md \ - --target main + --latest="${IS_LATEST}" - name: Create and push Docker tag if: steps.changesets.outputs.published == 'true' @@ -176,6 +245,7 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} with: image_tag: v${{ needs.release.outputs.published_package_version }} + is_latest: ${{ needs.release.outputs.is_latest == 'true' }} # Trigger Helm chart release directly via workflow_call (same GITHUB_TOKEN # limitation as the Docker path). Runs after Docker images are published so @@ -247,7 +317,11 @@ jobs: token: ${{ secrets.CROSS_REPO_PAT }} repository: triggerdotdev/trigger.dev-site-v3 event-type: new-release - client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}"}' + # is_latest is included so the marketing site's changelog can + # decide how to render lagged-line releases (e.g. demote to a + # secondary section, or skip headline placement). Existing site + # consumers ignoring the field are unaffected. + client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}", "is_latest": ${{ needs.release.outputs.is_latest }}}' # The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims. prerelease: diff --git a/scripts/enhance-release-pr.mjs b/scripts/enhance-release-pr.mjs index 6a446e794b..1dd9c04e62 100644 --- a/scripts/enhance-release-pr.mjs +++ b/scripts/enhance-release-pr.mjs @@ -31,6 +31,15 @@ const ROOT_DIR = join(import.meta.dirname, ".."); // --- Parse changeset PR body --- +/** + * Parse the changesets-generated PR body into a flat list of entries. + * Deduplicates by linked PR number, skips dependency-only bumps, and + * heuristically categorizes each entry as fix / feature / breaking / + * improvement based on its leading text. + * + * @param {string} body - Raw markdown body from the changesets release PR. + * @returns {Array<{text: string, type: 'fix' | 'feature' | 'breaking' | 'improvement'}>} + */ function parsePrBody(body) { const entries = []; if (!body) return entries; @@ -88,6 +97,13 @@ function parsePrBody(body) { const REPO = "triggerdotdev/trigger.dev"; +/** + * Run a git command in the repo root and return its trimmed stdout. + * Rejects with the underlying execFile error on non-zero exit. + * + * @param {string[]} args - argv passed to git (e.g. ["log", "--format=%H"]). + * @returns {Promise} + */ function gitExec(args) { return new Promise((resolve, reject) => { execFile("git", args, { cwd: ROOT_DIR, maxBuffer: 1024 * 1024 }, (err, stdout) => { @@ -97,6 +113,13 @@ function gitExec(args) { }); } +/** + * Find the commit that first added a file (used to attribute a + * `.server-changes/*.md` file back to the PR that introduced it). + * + * @param {string} filePath - Path relative to repo root. + * @returns {Promise} Commit SHA, or null if not found / git failed. + */ async function getCommitForFile(filePath) { try { // Find the commit that added this file @@ -107,6 +130,15 @@ async function getCommitForFile(filePath) { } } +/** + * Look up the PR that introduced a given commit. Prefers merged PRs and + * picks the earliest-merged one (matches @changesets/get-github-info). + * Requires GITHUB_TOKEN or GH_TOKEN; returns null without a token, on + * fetch failure, or when no PR is associated with the commit. + * + * @param {string} commitSha + * @returns {Promise} PR number, or null. + */ async function getPrForCommit(commitSha) { const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; if (!token || !commitSha) return null; @@ -139,6 +171,16 @@ async function getPrForCommit(commitSha) { // --- Parse .server-changes/ files --- +/** + * Read every `.server-changes/*.md` file (skipping README.md), parse + * frontmatter, and return the entries to render under "Server changes" in + * the enhanced PR body. Looks up the introducing PR for each file and + * appends a PR link if one is found and not already inline. Frontmatter + * `type` and `area` fields drive section grouping (defaults: improvement, + * webapp). + * + * @returns {Promise>} + */ async function parseServerChanges() { const dir = join(ROOT_DIR, ".server-changes"); const entries = []; @@ -189,6 +231,15 @@ async function parseServerChanges() { return entries; } +/** + * Minimal YAML frontmatter parser — splits a `---`-delimited header from + * the body and returns both. Frontmatter values are trimmed strings; no + * type coercion. Returns the whole content as `body` when no frontmatter + * is present. + * + * @param {string} content + * @returns {{frontmatter: Record, body: string}} + */ function parseFrontmatter(content) { const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { frontmatter: {}, body: content }; @@ -206,9 +257,54 @@ function parseFrontmatter(content) { // --- Format the enhanced PR body --- -function formatPrBody({ version, packageEntries, serverEntries, rawBody }) { +/** + * Render the enhanced release PR body. + * + * @param {object} args + * @param {string} args.version - Proposed release version (e.g. "4.4.6"). + * @param {Array<{text: string, type: string}>} args.packageEntries - Entries parsed from the changesets-generated PR body. + * @param {Array<{text: string, type: string, area: string}>} args.serverEntries - Entries parsed from .server-changes/*.md. + * @param {string} args.rawBody - The original changesets-generated PR body (kept in a collapsed details section). + * @param {{sourceBranch: string, currentLatest: string, willBeLatest: boolean, lineMatch: string|null}|null} args.releaseContext - Release-branch context (only set when SOURCE_BRANCH env is present); drives the "Release prep" header. + * @returns {string} Markdown body. + */ +function formatPrBody({ version, packageEntries, serverEntries, rawBody, releaseContext }) { const lines = []; + // Release-branch context header. Surfaces whether this PR will become the + // npm `latest` / Docker `:v4-beta` / GitHub "Latest" — surprising on + // release-branch hotfixes. + if (releaseContext) { + const { sourceBranch, currentLatest, willBeLatest, lineMatch } = releaseContext; + lines.push("## Release prep"); + lines.push(""); + lines.push(`- **Version:** \`${version}\``); + lines.push(`- **Source branch:** \`${sourceBranch}\``); + lines.push(`- **Current \`latest\` on npm:** \`${currentLatest}\``); + lines.push( + `- **This release will become \`latest\`:** ${ + willBeLatest + ? "✅ yes" + : `❌ no — will publish to dist-tag \`release-${lineMatch || "?"}\`` + }` + ); + if (sourceBranch && sourceBranch.startsWith("release/")) { + lines.push(""); + if (willBeLatest) { + lines.push( + `> Hotfix on the **${lineMatch}.x** line. Becomes \`latest\` because the current latest (${currentLatest}) is older. Customers running \`npm install\` will pick this up.` + ); + } else { + lines.push( + `> Hotfix on the **${lineMatch}.x** line. Will NOT become \`latest\` because main has shipped a higher version (${currentLatest}). Customers wanting this fix on the ${lineMatch}.x line should pin: \`npm install @trigger.dev/sdk@release-${lineMatch}\`.` + ); + } + } + lines.push(""); + lines.push("---"); + lines.push(""); + } + const features = packageEntries.filter((e) => e.type === "feature"); const fixes = packageEntries.filter((e) => e.type === "fix"); const improvements = packageEntries.filter((e) => e.type === "improvement" || e.type === "other"); @@ -306,6 +402,54 @@ function formatPrBody({ version, packageEntries, serverEntries, rawBody }) { // --- Main --- +/** + * Build release-branch context for the PR body header. + * + * Reads SOURCE_BRANCH from the environment (set by changesets-pr.yml). When + * present, queries npm for the current `latest` dist-tag of @trigger.dev/sdk, + * compares the proposed version against it, and returns context for rendering + * the "Release prep" header. Returns null when SOURCE_BRANCH is unset (so the + * header is omitted on plain main releases that don't need branch context). + * + * @returns {Promise<{sourceBranch: string, currentLatest: string, willBeLatest: boolean, lineMatch: string|null}|null>} + */ +async function getReleaseContext() { + const sourceBranch = process.env.SOURCE_BRANCH; + if (!sourceBranch) return null; + + // Look up current npm `latest` for the canonical package + let currentLatest = "0.0.0"; + try { + const out = await new Promise((resolve) => { + execFile( + "npm", + ["view", "@trigger.dev/sdk", "dist-tags.latest"], + { maxBuffer: 1024 * 1024 }, + (err, stdout) => resolve(err ? "" : stdout.trim()) + ); + }); + if (out && out !== "undefined") currentLatest = out; + } catch { + // fall through with default + } + + const cmp = (a, b) => + a.split(".").map(Number).reduce((acc, n, i) => acc || n - (b.split(".").map(Number)[i] ?? 0), 0); + const willBeLatest = cmp(version, currentLatest) > 0; + + const m = sourceBranch.match(/^release\/(\d+\.\d+)\.x$/); + const lineMatch = m ? m[1] : null; + + return { sourceBranch, currentLatest, willBeLatest, lineMatch }; +} + +/** + * Entry point. Reads the raw changesets PR body from CHANGESET_PR_BODY env + * or stdin, gathers package + server entries and release-branch context, + * and writes the enhanced markdown body to stdout. + * + * @returns {Promise} + */ async function main() { let rawBody = process.env.CHANGESET_PR_BODY || ""; if (!rawBody && !process.stdin.isTTY) { @@ -316,12 +460,14 @@ async function main() { const packageEntries = parsePrBody(rawBody); const serverEntries = await parseServerChanges(); + const releaseContext = await getReleaseContext(); const body = formatPrBody({ version, packageEntries, serverEntries, rawBody, + releaseContext, }); process.stdout.write(body);