Skip to content

Commit bb508ea

Browse files
authored
ci: require --tag for releases, route v3 template PRs to 3.x (#16360)
# Overview Main-side tooling for the v4 beta phase. Harden `pnpm release` against accidentally publishing to `latest`, route the post-release templates workflow by release major so v3 patches (published from `3.x`) produce the right PR on the right branch, and close script-injection patterns in that workflow. Companion to the pending `3.x` cutover. No functional changes to Payload itself. ## Key Changes - **`tools/releaser/src/release.ts` — require `--tag`; disallow `latest`** - Abort when `--tag` is missing. Previously an omitted tag flowed into `pnpm publish --tag undefined` and published to a literal `undefined` dist-tag. - Abort when `--tag latest` is passed. During the v4 beta phase, stable v4 must publish to `beta` so it cannot displace v3 on `latest`. - Both guards print a pointer to remove them at Phase 2 when v4 goes stable. - **`.github/workflows/post-release-templates.yml` — route by release tag** - Compute `target_branch` from the release tag: `v3.*` → `3.x`, everything else → `main`. - `update_templates` checks out `target_branch`, so templates are regenerated from the source that corresponds to the released major. - PR `base` now targets `target_branch` instead of hardcoded `main`. A v3 patch produces a templates PR against `3.x`; a v4 release produces one against `main`. - `workflow_dispatch` still works; it uses `git describe` to pick the latest non-v2 tag and routes the same way. - **`.github/workflows/post-release-templates.yml` — harden release-tag handling** - Pass `github.event.release.tag_name` through `env:` rather than interpolating `${{ … }}` directly into `run:` blocks (closes the classic GHA script-injection pattern). - Validate the tag against a semver shape before it fans out into step outputs and downstream steps; the job aborts on a malformed tag instead of letting shell-metacharacter content reach the branch name, PR title, or PR body. - Replace the remaining inline `${{ … release_tag }}` shell interpolations with `$RELEASE_TAG` from `env:`. ## Design Decisions - **Disallow `latest` rather than default to `beta`.** Explicit caller intent; Phase 2 revert is a two-line deletion. No branch-detection or magic defaults inside the releaser. - **Route rather than skip v3 in post-release-templates.** An earlier iteration skipped v3 events entirely. Routing preserves template regeneration for 3.x patches, which is the behavior the 3.x maintenance line needs. - **Workflow file stays on `main` only.** `release: published` events run the workflow from the repo default branch regardless, so the file does not need to exist on `3.x`. It only needs to check out `3.x` when handling a v3 release. - **Validate tag shape at the boundary, not everywhere downstream.** A single semver regex at the `determine_tag` step means all downstream outputs, branch names, and PR inputs inherit a safe character set without repeating validation. - **No changes to `post-release.yml`, `publish-prerelease.yml`, `main.yml`, `templates.ts`, or docs.** The release-commenter's existing `tag-filter: 'v\d'` already partitions v3/v4. Nightly canary cron is disabled in a separate prior commit and is expected to be re-enabled when v4 dev ramps up. ## Overall Flow ```mermaid flowchart TD A[pnpm release --bump X] --> B{--tag provided?} B -- no --> X1[abort] B -- yes --> C{tag == latest?} C -- yes --> X2[abort] C -- no --> D[publish to specified dist-tag] R[GitHub release published] --> V{tag matches semver?} V -- no --> X3[abort] V -- yes --> F{tag starts with v3?} F -- yes --> G[checkout 3.x, regen templates, PR base=3.x] F -- no --> H[checkout main, regen templates, PR base=main] ```
1 parent 88c8aac commit bb508ea

3 files changed

Lines changed: 79 additions & 14 deletions

File tree

.github/workflows/post-release-templates.yml

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jobs:
1515
runs-on: ubuntu-24.04
1616
outputs:
1717
release_tag: ${{ steps.determine_tag.outputs.release_tag }}
18+
target_branch: ${{ steps.determine_tag.outputs.target_branch }}
1819
steps:
1920
- name: Checkout
2021
uses: actions/checkout@v5
@@ -24,23 +25,42 @@ jobs:
2425

2526
- name: Determine Release Tag
2627
id: determine_tag
28+
env:
29+
EVENT_NAME: ${{ github.event_name }}
30+
EVENT_TAG: ${{ github.event.release.tag_name }}
2731
run: |
28-
if [ "${{ github.event_name }}" == "release" ]; then
29-
echo "Using tag from release event: ${{ github.event.release.tag_name }}"
30-
echo "release_tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
32+
if [ "$EVENT_NAME" == "release" ]; then
33+
echo "Using tag from release event: $EVENT_TAG"
34+
RELEASE_TAG="$EVENT_TAG"
3135
else
3236
# pull latest tag from github, must match any version except v2. Should match v3, v4, v99, etc.
3337
echo "Fetching latest tag from github..."
34-
LATEST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude 'v2*')
35-
echo "Latest tag: $LATEST_TAG"
36-
echo "release_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
38+
RELEASE_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude 'v2*')
39+
echo "Latest tag: $RELEASE_TAG"
40+
fi
41+
42+
# Validate shape before fanning out into outputs / downstream shell contexts.
43+
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
44+
echo "Refusing to proceed: release tag does not match semver: $RELEASE_TAG"
45+
exit 1
46+
fi
47+
48+
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
49+
50+
# Route v3 releases to the 3.x branch; everything else (v4+) to main.
51+
if [[ "$RELEASE_TAG" == v3.* ]]; then
52+
echo "target_branch=3.x" >> "$GITHUB_OUTPUT"
53+
else
54+
echo "target_branch=main" >> "$GITHUB_OUTPUT"
3755
fi
3856
3957
- name: Wait until latest versions resolve on npm registry
58+
env:
59+
RELEASE_TAG: ${{ steps.determine_tag.outputs.release_tag }}
4060
run: |
41-
./.github/workflows/wait-until-package-version.sh payload ${{ steps.determine_tag.outputs.release_tag }}
42-
./.github/workflows/wait-until-package-version.sh @payloadcms/translations ${{ steps.determine_tag.outputs.release_tag }}
43-
./.github/workflows/wait-until-package-version.sh @payloadcms/next ${{ steps.determine_tag.outputs.release_tag }}
61+
./.github/workflows/wait-until-package-version.sh payload "$RELEASE_TAG"
62+
./.github/workflows/wait-until-package-version.sh @payloadcms/translations "$RELEASE_TAG"
63+
./.github/workflows/wait-until-package-version.sh @payloadcms/next "$RELEASE_TAG"
4464
4565
update_templates:
4666
needs: wait_for_release
@@ -51,6 +71,8 @@ jobs:
5171
steps:
5272
- name: Checkout
5373
uses: actions/checkout@v5
74+
with:
75+
ref: ${{ needs.wait_for_release.outputs.target_branch }}
5476

5577
- name: Setup
5678
uses: ./.github/actions/setup
@@ -85,14 +107,15 @@ jobs:
85107
id: commit
86108
env:
87109
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110+
RELEASE_TAG: ${{ needs.wait_for_release.outputs.release_tag }}
88111
run: |
89112
set -ex
90113
git config --global user.name "github-actions[bot]"
91114
git config --global user.email "github-actions[bot]@users.noreply.github.com"
92115
93116
git diff --name-only
94117
95-
export BRANCH_NAME=templates/bump-${{ needs.wait_for_release.outputs.release_tag }}-$(date +%s)
118+
BRANCH_NAME="templates/bump-${RELEASE_TAG}-$(date +%s)"
96119
echo "branch=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
97120
98121
- name: Create pull request
@@ -103,7 +126,7 @@ jobs:
103126
author: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
104127
commit-message: 'templates: bump templates for ${{ needs.wait_for_release.outputs.release_tag }}'
105128
branch: ${{ steps.commit.outputs.branch }}
106-
base: main
129+
base: ${{ needs.wait_for_release.outputs.target_branch }}
107130
assignees: ${{ github.actor }}
108131
title: 'templates: bump for ${{ needs.wait_for_release.outputs.release_tag }}'
109132
body: |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
"prepare-run-test-against-prod:ci": "rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
9797
"publish-prerelease": "pnpm --filter releaser publish-prerelease",
9898
"reinstall": "pnpm clean:all && pnpm install",
99-
"release": "pnpm --filter releaser release --tag latest",
99+
"release": "pnpm --filter releaser release --tag beta",
100100
"runts": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" node --no-deprecation --no-experimental-strip-types --import @swc-node/register/esm-register",
101101
"script:audit": "pnpm audit -P",
102102
"script:audit:critical": "pnpm audit -P --audit-level critical",

tools/releaser/src/release.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
/**
2-
* Usage: GITHUB_TOKEN=$GITHUB_TOKEN pnpm release --bump <minor|patch>
2+
* Usage: GITHUB_TOKEN=$GITHUB_TOKEN pnpm release --bump <prerelease|prepatch|preminor|premajor>
33
*
44
* Ensure your GITHUB_TOKEN is set in your environment variables
55
* and also has the ability to create releases in the repository.
6+
*
7+
* During the v4 beta phase, this script:
8+
* - must be run from the `main` branch
9+
* - requires a v4.x version in root package.json
10+
* - requires --tag beta (or canary); 'latest' is disallowed
611
*/
712

813
import type { ExecSyncOptions } from 'child_process'
@@ -39,7 +44,7 @@ const {
3944
'dry-run': dryRun,
4045
'git-commit': gitCommit = true, // Whether to run git commit operations
4146
'git-tag': gitTag = true, // Whether to run git tag and commit operations
42-
tag, // Tag to publish to: latest, beta, canary
47+
tag, // Tag to publish to: beta, canary (latest disallowed during v4 beta phase)
4348
} = args
4449

4550
const logPrefix = dryRun ? chalk.bold.magenta('[dry-run] >') : ''
@@ -117,6 +122,42 @@ async function main() {
117122
throw new Error('Could not find version in package.json')
118123
}
119124

125+
if (!tag) {
126+
abort(
127+
`--tag is required. Use --tag beta (or canary). 'latest' is disallowed during v4 beta phase.`,
128+
)
129+
}
130+
if (tag === 'latest') {
131+
abort(
132+
`'latest' dist-tag is disallowed during the v4 beta phase. Use --tag beta. Remove this guard when v4 goes stable.`,
133+
)
134+
}
135+
136+
// v4 beta guards — remove when v4 goes stable
137+
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim()
138+
if (currentBranch !== 'main') {
139+
abort(`Releases must be run from 'main'. Current branch: ${currentBranch}.`)
140+
}
141+
142+
if (!/^4\./.test(monorepoVersion)) {
143+
abort(
144+
`Expected v4.x release; package.json version is ${monorepoVersion}. This script is pinned to v4 during beta phase.`,
145+
)
146+
}
147+
148+
const prerelease = semver.prerelease(monorepoVersion)
149+
const prereleaseId = prerelease?.[0]
150+
if (!prereleaseId) {
151+
abort(
152+
`Stable releases are disallowed during v4 beta phase. package.json version (${monorepoVersion}) has no prerelease identifier.`,
153+
)
154+
}
155+
if (prereleaseId !== tag) {
156+
abort(
157+
`Version/tag mismatch: version ${monorepoVersion} has prerelease '${prereleaseId}' but --tag is '${tag}'. These must match.`,
158+
)
159+
}
160+
120161
const nextReleaseVersion = semver.inc(monorepoVersion, bump, undefined, tag)
121162

122163
if (!nextReleaseVersion) {
@@ -145,6 +186,7 @@ async function main() {
145186
let packageDetails = await getPackageDetails(packagePublishList)
146187

147188
console.log(chalk.bold(`\n Version: ${monorepoVersion} => ${chalk.green(nextReleaseVersion)}\n`))
189+
console.log(chalk.bold.yellow(` Branch: ${currentBranch}`))
148190
console.log(chalk.bold.yellow(` Bump: ${bump}`))
149191
console.log(chalk.bold.yellow(` Tag: ${tag}\n`))
150192
console.log(chalk.bold.green(` Changes (${packageDetails.length} packages):\n`))

0 commit comments

Comments
 (0)