Repository navigation
PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow - #183
vladimir-arsov wants to merge 14 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…orkflow - Add fe-release-flow.yml: guard -> install-deps -> build || e2e -> release (tag + publish) -> GitHub release in one run - Artifacts tool: RELEASE_FLOW mode with FORCE; affected check since the previous tag of the line; outputs released/tag/previous_tag/is_latest - run-many: normalize an empty/undefined ref so GHA_REF '' selects all projects - Remove the cancelling concurrency from fe-install-deps.yml - Replace the fe-release caller template (push release/* + dispatch force), delete fe-tag-pushed template - Document the flow in the README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Benno42
left a comment
There was a problem hiding this comment.
Great! Clear flow, and the tests pass locally. Live validation is still open. maybe we can test it before merging.
| } else { | ||
| // affected since the previous release of the line, not since the pushed range: | ||
| // a push whose queued run was cancelled must not be missed | ||
| this.base = previousTag; |
There was a problem hiding this comment.
❓After a failed publish, "Re-run failed jobs" finds nothing affected and ends green without publishing, but design.md and plan.md promise a release. Is this intended, or should the run fail when the latest tag has no published artifact? 🤖
There was a problem hiding this comment.
Good catch, confirmed: with the previous tag as base, the dangling tag became the base and the re-run found nothing. Fixed by switching to publish before tag:
- the tool writes the version to
$RUNNER_TEMPbefore publishing, publishes all projects, then pushes the tag - a new cleanup step in the
releasejob (failure() || cancelled()) unpublishes that version via the existingONLY_DELETE_ARTIFACTS+TAGmode, unless the tag is already on the remote - the constructor-ordering bug is fixed so
TAGon arelease/*branch keeps its own version (needed for the cleanup)
Result: a version/* tag always means a complete release, and "Re-run failed jobs" (or the next push) releases the same version. design.md, plan.md and the README are updated accordingly.
…, caller concurrency - Publish first, then tag; a failed or cancelled release is unpublished by a cleanup step (ONLY_DELETE_ARTIFACTS + TAG), so a re-run releases the same version - Fix the constructor ordering: a TAG on a release branch keeps its own version - Guard fails while old fe-release/fe-tag callers exist; deprecation warnings in fe-tag.yml and workflows/fe-release.yml; link PFM-ISSUE-34282 - Concurrency moves to the caller templates (fe-pr, fe-main); README migration steps - Release job: plain npm ci, git identity via env, drop the redundant released output - calculateNewVersion returns a new Version instead of mutating the tag list - README recovery and design.md aligned (publish before tag, master only) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test-only: the live test of PR #183 should exercise build and release, not the consumer's e2e suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describe how the clone-cplace-fe test environment is built and the agent-executable live test cases for the consolidated release flow (Phase A), plus the follow-up cases for PFM-ISSUE-34282 (Phase B). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preflight P3 compared commit lists, so a commit that only adds a spec file on the feature branch marked the test branch as stale. Compare the changed files outside specs/ instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st run - P2 skips .github/workflow-templates, which keep @master on purpose - P5 checks unmasked registry paths; the printed .npmrc line is masked because JFROG_URL is a secret - lib.sh: dist-tag query needs -XGET; a missing latest release is recorded as "none" instead of the 404 body - 26.4.1 has 27 @cplace-next tarballs (the 28th match is a backend @cplace-assets package) - document the token permissions the tests need, and the separate read-only token for cplace-fe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…published Live test A8 showed that unpublishing a version drops every npm dist-tag pointing to it: after a failed release, 26 of 27 packages had no release-X.Y dist-tag at all until the next successful release. Publish the release under the staging tag release-X.Y-pending and move release-X.Y (promote phase) only after every project is published, before the version tag is pushed. A failed publish now leaves release-X.Y on the previous version. A failed promotion fails the run and is cleaned up like a failed publish. Also update the live test document: the staging-tag expectations, the A7 follow-up commit ([branch-off] tip blocks even forced releases), the long-path clone note, CRLF-safe jq and the job_log helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt cancelling Live test A9: npm unpublish reported success for cf-devkit@26.4.5 and deleted nothing. deleteArtifact now checks that the version is gone, rechecks once after a delay for lagging metadata, retries the unpublish once, and fails the step if the version survives. Cancelling a release during the publish is documented as not supported: the publisher survives the cancel as an orphan and keeps publishing while the cleanup runs. The README describes the recovery by re-runs, which A9 verified. The live-test document gets the updated A9 case and reads one run attempt in capture(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| console.log( | ||
| `Promote phase: moving ${npmTag} to ${this.calculatedNewVersion}` | ||
| ); | ||
| this.projects.forEach((project) => project.promote()); |
There was a problem hiding this comment.
Promote runs before the tag push, so a failure after promote still drops release-X.Y. The staging tag exists so that a failed run leaves release-X.Y on the previous version. But promote() moves release-X.Y to the new version before git tag/git push. If the push fails (PAT expired, branch protection on tags, network), or the run is cancelled between promote and push, or promote() hits process.exit(1) on project k after projects 1..k-1 were already promoted, the cleanup step sees no remote tag and unpublishes the version. That deletes every dist-tag pointing to it, so release-X.Y disappears for the promoted packages instead of staying on the previous version. Consumers resolving @scope/pkg@release-X.Y then break. Consider pushing the tag first and promoting afterwards (a promote failure after the push then only needs a re-run of promote, not an unpublish), or restoring release-X.Y to previousTag's version in the cleanup path.
There was a problem hiding this comment.
Agreed on the analysis: unpublishing drops release-X.Y instead of putting it back on the previous version. In practice our consumers pin exact versions or ranges like ^26.4, and package-lock.json is mandatory, so npm ci installs the locked version without resolving the tag. Only a fresh resolve of the tag (npm update, npm install <pkg>@release-X.Y) fails until the next successful release. Restoring the tag in the cleanup would add an untested error path for a tag nobody resolves, so we documented it in Recovery, including the manual npm dist-tag add fix (62410ac).
| exit 0 | ||
| fi | ||
| tag=$(cat "$file") | ||
| if git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null; then |
There was a problem hiding this comment.
A failing git ls-remote is treated as "tag not pushed", which unpublishes a finished release. git ls-remote --exit-code exits 2 only when the ref is missing; auth or network errors exit 128. The if treats any non-zero exit as "not pushed". So if the release step failed after the tag was pushed (or was cancelled) and the remote check then hits a transient error, the next step unpublishes a version whose tag already exists. The tag then points to a release with no artifacts, and the next run computes the next patch, so that version is lost. Check for exit code 2 explicitly and fail the step on any other code, for example git ls-remote ...; rc=$?; if [ $rc -eq 0 ]; then ...; elif [ $rc -ne 2 ]; then echo '::error::...'; exit 1; fi.
There was a problem hiding this comment.
Good catch, fixed in 62410ac. The check now acts on the exit code: 0 means pushed and nothing is cleaned up, 2 means not pushed and the version is unpublished. Any other code fails the step with an ::error:: that names the code, and nothing is unpublished, so a transient auth or network error can no longer remove a finished release.
| # a new push to the branch cancels the run of the previous one | ||
| concurrency: | ||
| group: fe-main-${{ github.ref }} | ||
| cancel-in-progress: true |
There was a problem hiding this comment.
cancel-in-progress: true on the main workflow can cancel a snapshot publish mid-run, and the next run never publishes those projects. Before this change only the install-deps job was cancellable. Now a second push to main cancels the whole previous run, including the snapshot job while it publishes. The new run uses GHA_BASE: ${{ github.event.before }}, which is the cancelled push's head. So projects that were affected only by the cancelled push are not affected in the new run, and they get no snapshot until they change again. Some may also be left half-published. The release flow already guards against this ("a push whose queued run was cancelled must not be missed"), but main snapshots do not. Use cancel-in-progress: false here, or keep cancellation scoped to the jobs before snapshot.
There was a problem hiding this comment.
Agreed, a main build must not be cancelled. The fe-main template now has no concurrency group (62410ac), so every push runs to completion and publishes its snapshot. A group with cancel-in-progress: false wouldn't be enough: GitHub keeps one pending run per group and drops it when a third push arrives, and the next run's github.event.before base would skip that push's projects. The README Concurrency section explains this.
| console.warn( | ||
| `${scopedPackage}@${version.toString()} is still in the registry after npm unpublish reported success, retrying` | ||
| ); | ||
| this.unpublish(version); |
There was a problem hiding this comment.
The retry runs npm unpublish again on a version that is probably already gone, and its E404 now fails the job. isStillPublished returns true when registry metadata still lists the version after 5s. That is the stale-metadata case the doc comment describes, and JFrog npm metadata recalculation can take longer than 5s. The second this.unpublish(version) then gets a 404 for the already-deleted version. execSync throws, the outer catch sees status !== 0, and it calls process.exit(1). So a successful deletion now fails fe-pr-close, fe-cleanup-snapshots, PR snapshots (which delete before publishing) and the release cleanup. npm show can also return a cached packument because --prefer-online is missing. Treat E404 from the retry unpublish as success (reuse isMissingFromRegistry), and add --prefer-online to the existence check.
There was a problem hiding this comment.
Thanks, valid (this also covers the finding on the 5s sleep). On reflection we removed the check and retry completely in 62410ac. It was added after one observation in live test A9: during the recovery of a deliberately cancelled release job, npm unpublish reported success for one package and deleted nothing. We couldn't reproduce it, and the cause is unknown.
Cancelling a running release job is not supported and shouldn't happen. If it does, the README Recovery section documents the cleanup. A package that survives the cleanup makes the next re-run fail with E403 on that package, and that re-run's cleanup unpublishes the version again; if it still survives, it is unpublished by hand. A check with a retry and a sleep in the shared deleteArtifact path wasn't worth it for this case. deleteArtifact is back to one existence check and one unpublish.
|
|
||
| console.log(`Tag phase: pushing ${gitTag}`); | ||
| console.log(execSync(`git tag ${gitTag}`).toString()); | ||
| console.log(execSync(`git push origin ${gitTag}`).toString()); |
There was a problem hiding this comment.
If the run dies after git push but before writeOutput, the GitHub release is never created and nothing retries it. Cancellation or a runner loss in that window means the cleanup sees the remote tag and does nothing, which is correct. But tag is never written, so github-release is skipped. github-release also needs release to succeed, so a later failure in this step skips it as well. The next push computes affected projects since this tag, finds nothing, and returns, so the GitHub release for this tag is never created. Write the outputs, or at least tag, right after the push and before anything else can fail, and consider if: !cancelled() && needs.release.outputs.tag != '' on github-release.
There was a problem hiding this comment.
The window is the single line between git push and writeOutput, and only the step summary follows the output. Dying there takes a cancel, which is not supported for a running release (README), or a runner loss. Neither would be caught by !cancelled(), because the output isn't written in that case. If it does happen, the README covers it: gh release create version/X.Y.Z --verify-tag --generate-notes. So no code change here.
| version: Version | ||
| ): Promise<boolean> { | ||
| if (!this.packageExists(scopedPackage, version.toString())) return false; | ||
| await NxProject.sleep(NxProject.UNPUBLISH_RECHECK_DELAY_MS); |
There was a problem hiding this comment.
A 5s sleep is added to the shared deleteArtifact path, which runs once per version. deleteArtifact is shared by deleteSnapshots (a loop over every snapshot of every project), fe-pr-close, and PR snapshots. Whenever metadata is briefly stale, each deletion now waits 5s in sequence, so a cleanup run over N snapshots can take N×5s longer. Before this change it did one existence check and one unpublish. Consider running the post-unpublish verification only in the release cleanup (where a lingering version blocks the re-release), or checking all versions once at the end.
There was a problem hiding this comment.
Resolved by removing the check completely in 62410ac, see the reply on the retry finding above: it only covered one unexplained case in the unsupported cancel scenario, and deleteArtifact is back to one existence check and one unpublish, without a sleep.
| jobs: | ||
| install-deps: | ||
| runs-on: ubuntu-latest | ||
| concurrency: |
There was a problem hiding this comment.
Removing the job-level concurrency also removes cancellation for every consumer that has not copied the new fe-main/fe-pr templates. Workflow templates are copied into consumer repos by hand, and the new workflow-level concurrency blocks only exist in this repo's templates. Repos still on the old fe-pr.yml lose their only cancel-on-new-push. Every push to a PR then runs a full, uncancelled install, build, code-quality and e2e matrix in parallel with the previous one. Either call out the template update as required in the migration notes, or keep a concurrency group here that the release flow opts out of (for example, an input that sets the group to github.run_id for the release flow).
There was a problem hiding this comment.
Intended. A job-level group in fe-install-deps only ever cancels an older run that is still in install-deps, so it only works when the two runs overlap in that window. Concurrency now belongs to the callers, where it always applies. The migration of the consumer repos is announced, and step 3 of the README covers the caller updates: fe-pr adds the cancelling block, and fe-main drops any block.
| # an old tag-push caller would publish manually pushed tags and fail on the tags of this flow | ||
| - name: Check for old release callers | ||
| run: | | ||
| old=$(grep -lE 'github-actions/\.github/workflows/(fe-release|fe-tag)\.yml@' .github/workflows/*.y*ml 2> /dev/null || true) |
There was a problem hiding this comment.
The old-caller guard matches comments and any other use of fe-tag.yml/fe-release.yml, so it can block every release. grep -lE also matches a commented-out line such as # uses: .../fe-release.yml@master, which is a common way to keep the old caller around during migration. It also matches a workflow that uses fe-release.yml with a non-tag trigger. In both cases the guard fails every release, push or dispatch, until the file is edited. Ignore comment lines (for example grep -vE '^\s*#' before matching), and only flag files that also have a tag-push trigger.
There was a problem hiding this comment.
Agreed on comments: since 62410ac the guard ignores lines where the reference follows a #, so a commented-out caller no longer blocks releases. I kept it for every trigger, though. The old fe-tag.yml caller runs on branch pushes, not tag pushes, and would tag in parallel with the new flow. A workflows/fe-release.yml caller with a dispatch or other trigger would also publish. Any active caller of either is a conflict, and the error message says what to do.
| return Promise.resolve(); | ||
| } | ||
| if ( | ||
| if (this.releaseFlow) { |
There was a problem hiding this comment.
A forced dispatch on a branch whose tip is a [branch-off] commit succeeds silently and publishes nothing. The branch-off check above returns before tagAndPublishRelease, regardless of FORCE. A maintainer who dispatches with force: true on a fresh release/X.Y, or on the default branch right after branch-off, gets a green run with no tag, no publish and no step summary explaining why. Either let force bypass the branch-off check in the release flow, or write a step summary or warning such as "FORCE ignored: tip is a branch-off commit".
There was a problem hiding this comment.
The skip is intended: branch-off already released what the [branch-off] commit contains, so force must not release it again (covered by a test and live test A7b). The silence was a fair point: since 62410ac a forced dispatch on such a tip prints ::warning::FORCE is ignored: … and writes it to the step summary. The README mentions it under Triggers.
| allTags | ||
| ); | ||
| const previousTag = previous.isValid() ? previous.getGitTag() : ''; | ||
| this.calculatedNewVersion = Utils.calculateNewVersion( |
There was a problem hiding this comment.
Simplification: calculateNewVersion looks up the same latest tag that was just computed as previous. previous is getLatestTagForReleaseBranch(branch, versionOfBranch, allTags), and calculateNewVersion immediately calls the same function with the same arguments. The new version can be derived directly: previous.isValid() ? previous+1 : branchVersion.1. Alternatively, have calculateNewVersion return {previous, next}. That removes the duplicate lookup and the risk of the two diverging.
There was a problem hiding this comment.
Done in 62410ac: Utils.nextVersion(branch, previous) computes the next version from the already computed previous tag. The release flow uses it directly, and calculateNewVersion (still used by the old bump mode) delegates to it.
Kaltenbach
left a comment
There was a problem hiding this comment.
Please the see review findings above
- Incomplete-release check: act on the git ls-remote exit code. 0 = pushed, 2 = not pushed (unpublish), anything else fails the step without unpublishing, so a transient error cannot remove a finished release. - Old-caller guard: ignore commented-out references; any active caller of fe-release.yml or fe-tag.yml still blocks the release. - [branch-off] tip with FORCE: still skipped, now with a warning and a step summary line. - Utils.nextVersion derives the next version from the previous tag the release flow already looked up; calculateNewVersion delegates to it. - fe-main template: no concurrency group, so every push publishes its snapshot. The README migration step and Concurrency section say why. - Revert the unpublish check and retry of 37dc554. It covered one unexplained case in the unsupported cancel scenario; a leftover package is removed by the next re-run's cleanup, as documented in Recovery. - README Recovery: a failed promote or tag push leaves release-X.Y missing until the next release; npm ci and lock-file installs are unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Replaces the two-run FE 2.0 release (branch push →
fe-tag.ymlpushes a tag → tag push →fe-release.ymlpublishes) with one run per push torelease/*or manual dispatch:guard → install-deps → build ∥ e2e → release (publish + promote + tag, one job) → GitHub release
Ticket: PFM-ISSUE-34283
Changes
.github/workflows/fe-release-flow.yml(new reusable workflow)FORCEandRUN_E2Eguardjob that fails a dispatch on any ref other than the default branch orrelease/*, and fails while an old release caller (workflows/fe-release.yml/workflows/fe-tag.yml) is still in the treereleasejob that publishes, promotes and tags, and unpublishes the version again if the publish, the promote or the tag push failsgithub-releasejob that usesGITHUB_TOKEN, sets the notes range from the previous tag of the line, and marks "Latest" only on the highestversion/*tagRELEASE_FLOWmode. It computes the next version, then checks for affected projects since the previous tag of the line (skipped whenFORCEis set or for X.Y.1).release-X.Y-pending, thenrelease-X.Yis moved to the new version, then the tag is pushed. Aversion/*tag always stands for a complete release, and a failed release leavesrelease-X.Yon the previous version.npm unpublish(it can report success and delete nothing), retries once, and fails the step if the version survives.released,tag,previous_tagandis_latest, exposed through the composite action.[branch-off]guard still runs first.run-manyturns an empty or undefined ref into'', soGHA_REF: ''selects all projects again. This was the original intent: pushes to main and release branches build all projects, PRs build only the affected ones.fe-install-deps.ymlloses its job-levelcancel-in-progressconcurrency. Each caller sets its own concurrency: the release caller is non-cancelling, the PR template cancels superseded runs, and the main template has no group, so every push publishes its snapshot.fe-release.ymlis replaced: push torelease/*, plusworkflow_dispatchwithforce, plussecrets: inherit. Thefe-tag-pushed.ymltemplate is deleted.force, secrets, recovery, concurrency.Behavior notes
[branch-off]commits are never tagged or published.Not in scope
version/*tag ruleset, a tag-triggered recovery path, and skip-if-exists or resumable publish.GITHUB_TOKEN(34282), immutable releases (34284), retiringGIT_USER*(34286).fe-tag.yml/workflows/fe-release.yml. Unmigrated callers keep the old flow.25.4–26.4don't get the new workflow. The first line with it is the next branch-off.Verification
affected-projects,artifacts-handler-release-flow(including[branch-off]over force),utilstests pass. The only failures are the known Windows path-separator baseline tests, which also fail onmaster.tsc --noEmitis clean.Live validation (Phase A, 2026-11-02 to 2026-11-03; A6 and A8 retested 2026-11-06)
Run in the fork
collaborationFactory/clone-cplace-feagainst the test registrycplace-npm-test, with the caller pointing at the test branchtest/PFM-ISSUE-34283-release-flow. Procedure and per-case details:clone-cplace-fe-live-tests.md.force: falseguardfails, nothing else runsguardfails "Old release callers found". Retest after the review fixes (62410ac) PASS: an active caller still blocks, a commented-out one does not[branch-off]tip commitrelease-26.4from 26 of 27 packages. Fixed with the staging tag (3de1099); retest PASS:release-26.4kept, re-run released the same 26.4.4. Retest after the review fixes (62410ac) PASS: the cleanup's tag check (exit code 2, tag not pushed) unpublished all 27, re-run released the same 26.4.7npm unpublishreporting success without deleting; not reproducible locally. An unpublish check with retry was added and reverted in review: the next re-run's cleanup unpublishes again, and the README documents the manual fallback. Recovery by re-run PASS: 26.4.5. Documented in the READMEgithub-releaserelease/26.3release-26.4unchangedmain(snapshot)Phase B (tag push with
GITHUB_TOKEN) follows in PFM-ISSUE-34282.🤖 Generated with Claude Code