1+ # release / publish
2+ #
3+ # Single npm OIDC entry point for both stable releases and prerelease canaries.
4+ # npm trusted publisher records for the 15 @copilotkit/* monorepo packages plus
5+ # @copilotkitnext/angular are registered against THIS workflow file. Matching
6+ # happens on the OIDC token's `workflow_ref` claim, which is always
7+ # publish-release.yml when this workflow is the entry point.
8+ #
9+ # Triggers:
10+ # - pull_request: closed on a release/publish/<scope>/v<X.Y.Z> branch → stable
11+ # release of <scope> at version <X.Y.Z> (the normal flow).
12+ # - workflow_dispatch with mode=stable → manual retrigger of a failed stable
13+ # release. Republishes from the latest commit on main. Only use this when
14+ # the normal flow failed BEFORE npm publish succeeded.
15+ # - workflow_dispatch with mode=prerelease → canary publish. Bumps versions
16+ # in the build job to <X.Y.Z>-canary.<suffix>, publishes with --tag canary,
17+ # skips tag push + GH Release + Notion notification.
118name : release / publish
219
320on :
421 pull_request :
522 types : [closed]
623 branches : [main]
7- # Escape hatch for failed releases. Covers failures BEFORE the "Publish to
8- # npm" step succeeds (build errors, infra blips, git config bugs). Does
9- # NOT cover post-npm-publish failures: if npm publish already succeeded
10- # but a later step failed, the retry will fail at "Publish to npm" with
11- # an "already published" error, and the "Check for pre-existing tags"
12- # step will also block retries where the tag was pushed. For those cases,
13- # remediate by hand using release-notes.md from the merged release PR.
1424 workflow_dispatch :
1525 inputs :
1626 scope :
17- description : |
18- ⚠️ MANUAL RETRIGGER — republishes from the latest commit on main,
19- bypassing the normal release-PR flow. Only use this when the
20- normal flow failed BEFORE npm publish succeeded. If npm publish
21- already happened, finish the release by hand instead — dispatching
22- here will fail at "Publish to npm" with "already published".
27+ description : " What to release"
2328 required : true
2429 type : choice
2530 options :
2631 - monorepo
2732 - angular
28- dry-run :
29- description : " Dry run (skip publish step)"
30- required : false
31- default : false
32- type : boolean
33- workflow_call :
34- inputs :
35- scope :
36- required : true
37- type : string
3833 mode :
34+ description : " Release mode: stable (full release with tag + GH Release) or prerelease (canary, no tag/release)"
3935 required : false
40- type : string
4136 default : stable
42- dry-run :
43- required : false
44- type : boolean
45- default : false
46- publish-script :
37+ type : choice
38+ options :
39+ - stable
40+ - prerelease
41+ suffix :
42+ description : " Canary suffix (only used when mode=prerelease). Falls back to timestamp if empty. Allowed: [a-zA-Z0-9._-]+"
4743 required : false
4844 type : string
49- default : publish-release.ts
50- secrets :
51- NOTION_API_KEY :
45+ default : " "
46+ dry-run :
47+ description : " Dry run (skip publish step) "
5248 required : false
49+ default : false
50+ type : boolean
5351
5452permissions :
5553 contents : read
6361
6462jobs :
6563 build :
66- # Run on a merged release PR (normal flow) or a manual workflow_dispatch (escape hatch).
67- # Skipped on workflow_call: the caller's build job uploads the "workspace" artifact, so
68- # the callee's publish job downloads it directly without re-building .
64+ # Run on a merged release PR (normal flow) or a manual workflow_dispatch
65+ # from main (escape hatch). The main-branch guard on workflow_dispatch
66+ # prevents republishing from arbitrary branches .
6967 if : >
70- github.event_name != 'workflow_call' && (
71- github.event_name == 'workflow_dispatch' ||
72- (github.event.pull_request.merged == true &&
73- startsWith(github.event.pull_request.head.ref, 'release/publish/'))
74- )
68+ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
69+ (github.event.pull_request.merged == true &&
70+ startsWith(github.event.pull_request.head.ref, 'release/publish/'))
7571 runs-on : ubuntu-latest
7672 timeout-minutes : 20
7773 permissions :
7874 contents : read
7975 steps :
80- - name : Determine scope
76+ - name : Determine scope and mode
8177 id : meta
8278 env :
8379 PR_HEAD_REF : ${{ github.event.pull_request.head.ref }}
8480 INPUT_SCOPE : ${{ inputs.scope }}
81+ INPUT_MODE : ${{ inputs.mode }}
8582 run : |
83+ set -euo pipefail
8684 if [ -n "$INPUT_SCOPE" ]; then
8785 SCOPE="$INPUT_SCOPE"
8886 else
8987 # Branch format: release/publish/<scope>/v<version>
9088 SCOPE=$(echo "$PR_HEAD_REF" | sed 's|release/publish/\([^/]*\)/v.*|\1|')
9189 fi
92- echo "scope=$SCOPE" >> $GITHUB_OUTPUT
93- echo "Detected scope: $SCOPE"
90+ MODE="${INPUT_MODE:-stable}"
91+ echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
92+ echo "mode=$MODE" >> "$GITHUB_OUTPUT"
93+ echo "Detected scope: $SCOPE, mode: $MODE"
9494
9595 # No token/credential persistence: the publish job sets up its own
9696 # `git config insteadOf` with secrets.GITHUB_TOKEN before pushing tags,
@@ -117,6 +117,27 @@ jobs:
117117 - name : Install Dependencies
118118 run : pnpm install --frozen-lockfile
119119
120+ # Validate user-supplied suffix against npm-safe charset before passing
121+ # to bump-prerelease.ts. Empty suffix → omit the --suffix flag entirely
122+ # so the script applies its timestamp fallback (passing an empty string
123+ # would produce a version like "X.Y.Z-canary." with a trailing dot).
124+ - name : Bump prerelease versions
125+ if : ${{ steps.meta.outputs.mode == 'prerelease' }}
126+ env :
127+ INPUT_SCOPE : ${{ inputs.scope }}
128+ INPUT_SUFFIX : ${{ inputs.suffix }}
129+ run : |
130+ set -euo pipefail
131+ if [ -n "$INPUT_SUFFIX" ]; then
132+ if ! [[ "$INPUT_SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then
133+ echo "::error::Invalid suffix '$INPUT_SUFFIX'. Allowed: [a-zA-Z0-9._-]+"
134+ exit 1
135+ fi
136+ pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE" --suffix "$INPUT_SUFFIX"
137+ else
138+ pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE"
139+ fi
140+
120141 - name : Build packages
121142 run : pnpm run build
122143
@@ -140,17 +161,11 @@ jobs:
140161
141162 outputs :
142163 scope : ${{ steps.meta.outputs.scope }}
164+ mode : ${{ steps.meta.outputs.mode }}
143165
144166 publish :
145167 needs : build
146- # Run when build succeeded (normal flow) OR was skipped specifically because this is a
147- # workflow_call invocation (the caller already built and uploaded the artifact). The
148- # workflow_call branch is the ONE intentional skip — other skipped paths (notably a
149- # `pull_request: closed` event where the PR was closed WITHOUT merging, which makes the
150- # build job's own merged-true guard fail and skip) must NOT trigger publish. The default
151- # `if: success()` would also skip publish on legitimate workflow_call invocations, hence
152- # the explicit allowlist below.
153- if : ${{ !cancelled() && (needs.build.result == 'success' || (needs.build.result == 'skipped' && github.event_name == 'workflow_call')) }}
168+ if : ${{ !cancelled() && needs.build.result == 'success' }}
154169 runs-on : ubuntu-latest
155170 timeout-minutes : 20
156171 environment : npm
@@ -164,39 +179,33 @@ jobs:
164179 PR_HEAD_REF : ${{ github.event.pull_request.head.ref }}
165180 INPUT_SCOPE : ${{ inputs.scope }}
166181 INPUT_MODE : ${{ inputs.mode }}
167- EVENT_NAME : ${{ github.event_name }}
168182 run : |
169183 set -euo pipefail
170- # Scope: workflow_call and workflow_dispatch both populate inputs.scope;
171- # pull_request parses scope from the release branch name.
172184 if [ -n "$INPUT_SCOPE" ]; then
173185 SCOPE="$INPUT_SCOPE"
174186 else
175187 # Branch format: release/publish/<scope>/v<version>
176188 SCOPE=$(echo "$PR_HEAD_REF" | sed 's|release/publish/\([^/]*\)/v.*|\1|')
177189 fi
178190 if [ -z "$SCOPE" ]; then
179- echo "::error::Failed to resolve scope (event=$EVENT_NAME, input=$INPUT_SCOPE, ref=$PR_HEAD_REF)"
191+ echo "::error::Failed to resolve scope (input=$INPUT_SCOPE, ref=$PR_HEAD_REF)"
180192 exit 1
181193 fi
182- # Mode: only workflow_call carries an explicit mode; everything else is stable.
183- if [ "$EVENT_NAME" = "workflow_call" ]; then
184- MODE="${INPUT_MODE:-stable}"
185- else
186- MODE="stable"
187- fi
194+ MODE="${INPUT_MODE:-stable}"
188195 echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
189- echo "mode=$MODE" >> "$GITHUB_OUTPUT"
190- echo "Resolved scope=$SCOPE mode=$MODE (event=$EVENT_NAME)"
196+ echo "mode=$MODE" >> "$GITHUB_OUTPUT"
191197
192198 - name : Download workspace
193199 uses : actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
194200 with :
195201 name : workspace
196202
197203 - name : Configure git credentials
204+ env :
205+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
198206 run : |
199- git config --local url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/"
207+ set -euo pipefail
208+ git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
200209
201210 - name : Setup pnpm
202211 # Omit `version:` so pnpm/action-setup inherits from the repo's
@@ -229,20 +238,26 @@ jobs:
229238 env :
230239 NODE_AUTH_TOKEN : " "
231240 NOTION_API_KEY : ${{ steps.meta.outputs.mode == 'stable' && secrets.NOTION_API_KEY || '' }}
232- PUBLISH_SCRIPT : ${{ inputs.publish-script || 'publish-release.ts' }}
241+ PUBLISH_SCRIPT : ${{ steps.meta.outputs.mode == 'prerelease' && 'prerelease.ts' || 'publish-release.ts' }}
233242 SCOPE : ${{ steps.meta.outputs.scope }}
234243 run : pnpm tsx "scripts/release/$PUBLISH_SCRIPT" --scope "$SCOPE"
235244
236245 - name : Verify publish step emitted version
237- if : ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
246+ if : ${{ success() && inputs.dry-run != true }}
238247 env :
248+ MODE : ${{ steps.meta.outputs.mode }}
239249 VERSION : ${{ steps.publish.outputs.version }}
240250 run : |
251+ set -euo pipefail
241252 if [ -z "$VERSION" ]; then
242- echo "::error::publish step did not emit 'version' output. This indicates the publish-release.ts script (or the configured publish-script input) did not write to GITHUB_OUTPUT. Tag/release creation would produce malformed artifacts; aborting."
253+ if [ "$MODE" = "prerelease" ]; then
254+ echo "::error::prerelease.ts did not emit 'version' output to GITHUB_OUTPUT. The Prerelease summary would render a blank Version field; aborting."
255+ else
256+ echo "::error::publish-release.ts did not emit 'version' output to GITHUB_OUTPUT. Tag/release creation would produce malformed artifacts; aborting."
257+ fi
243258 exit 1
244259 fi
245- echo "VERSION=$VERSION confirmed"
260+ echo "VERSION=$VERSION confirmed (mode=$MODE) "
246261
247262 - name : Configure git user
248263 if : ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
0 commit comments