From 7ec004a393a5e7f38ca7a307857be08b50eb496b Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Mon, 19 Oct 2026 17:19:51 +0200 Subject: [PATCH 01/11] PFM-ISSUE-34283 - Add research, design and plan Co-Authored-By: Claude Opus 5.5 (1M context) --- .../design.md | 312 +++++++ .../plan.md | 821 ++++++++++++++++++ .../research.md | 405 +++++++++ 3 files changed, 1538 insertions(+) create mode 100644 specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md create mode 100644 specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md create mode 100644 specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/research.md diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md new file mode 100644 index 00000000..0ec1324f --- /dev/null +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md @@ -0,0 +1,312 @@ +--- +date: 2026-10-19 +git_commit: faa477146b7039659f88a128b9b364f64b1eddae +branch: master +topic: "Consolidate the FE 2.0 release flow (fe-tag + fe-release) into a single workflow" +tags: [design, github-actions, fe-release, fe-tag, artifacts, versioning, jfrog, github-release, concurrency, workflow-dispatch] +status: draft +--- + +# Consolidate the FE 2.0 Release Flow into a Single Workflow - Design Approach + +## Overview + +Replace today's two chained runs per FE repo (branch push → `fe-tag`, then tag push → `fe-release`) with one reusable workflow, `fe-release-flow.yml`. Each caller invokes it with a single `uses:`. Within one run it does build + test → push the version tag → publish to JFrog → create the GitHub release. It also adds a `workflow_dispatch` path with a `force` input as the supported alternative to tagging by hand. This ticket delivers the github-actions side (workflow, tool changes, template, docs) on `master` and `release/25.4` … `release/26.4`. Consumer migration starts with the pilot PFM-ISSUE-34287. + +## Problem Statement + +The release is currently two workflow runs connected only by a PAT-pushed tag: +- Any `*/*` tag (manual, UI-created, stray) starts a full publish. +- A failed publish leaves a tag with no or partial artifacts, and a re-run fails with E403 on the first already-published package. +- No GitHub release is created anywhere. + +The split exists only because a tag pushed with `GITHUB_TOKEN` doesn't trigger workflows. A single run removes that coupling, which is the prerequisite for the `GITHUB_TOKEN` switch (PFM-ISSUE-34282). + +### Requirements +- One workflow, triggered by `push: branches: ['release/*']` and by `workflow_dispatch` with a user-selectable `force` input. Manual tag creation does not trigger a release, as far as workflow files can enforce it (a tag ruleset is deferred). +- Order within the run: build + test → push version tag → publish to JFrog → create the GitHub release. +- Tag-before-publish semantics: a failed tag push retries the same version, and a failed publish moves the next run to the next version. +- The GitHub release is created only after a successful publish. +- Retries are deadlock-free. + +### Constraints +- Works under the current auth: the tag is still pushed with the PAT (`GIT_USER_TOKEN`, actor `squad-fe`). Only the GitHub release uses `GITHUB_TOKEN`. +- Because of the PAT push, a migrated branch's tree must not contain the old `on: push: tags` caller, or it publishes a second time. +- The existing `fe-tag.yml` / `fe-release.yml` stay untouched for unmigrated branches and repos. +- Every github-actions change is made per release branch (`master`, `release/25.4` … `26.4`) along the upmerge chain, with per-branch refs. +- Never test on `release/*`; validation uses the prepared live test setup (2026-06-08 incident rule). + +## Design Decisions Summary + +1. **One reusable workflow, `fe-release-flow.yml`, called from one caller file**: all release jobs live in github-actions; callers are small and identical. + - Jobs: `guard` → `install-deps` → `build` ∥ `e2e` → `release` → `github-release`. The build blocks reuse the existing `fe-install-deps`, `fe-build` and `fe-e2e` as nested reusables. + - E2e can be switched off with a `RUN_E2E` input (default `true`). The `release` job accepts a skipped e2e. + - Callers on all migrated branches carry the same file, normalized to `fe-release.yml`. On `release/*` it fires on push to that branch and on dispatch. On the default branch only dispatch is live. That copy is the seed that cf-devkit branch-off copies into each new `release/X.Y`. +2. **Concurrency is caller-level run policy; reusable workflows have none**: + - The caller template declares `concurrency: { group: fe-release-${{ github.ref }}, cancel-in-progress: false }`. + - The cancelling job-level group in `fe-install-deps.yml:16-18` is removed entirely. +3. **Tag and publish happen in one tool process (new combined release mode)**: the artifacts tool computes the version from remote tags, applies the "anything affected?" check (skipped by `force`), tags and pushes, then publishes all publishable projects at exactly that version. + - It writes `released`, `tag`, `previous_tag` and `is_latest` to `$GITHUB_OUTPUT`. + - The existing `ONLY_BUMP_VERSION` and TAG modes are unchanged. The constructor-ordering bug (the release-branch block resets the TAG version to `X.Y.0`) is avoided by design in the new mode. + - Version calculation, tag and publish sit in the same job, so "Re-run failed jobs" recomputes and moves on to the next patch. +4. **`workflow_dispatch` with `force` (default `false`), guarded to the default branch and `release/*`**: + - A `guard` job fails a dispatch on any other ref and skips the run's later jobs for `squad-fe` pushes. + - On dispatch, build, storybook and e2e cover **all** projects. This needs a `run-many` fix so its existing "all projects" branch can be reached. + - `force` on `release/*` produces a forced release. `force` on the default branch publishes a snapshot of all projects (unique `0.0.0-SNAPSHOT-…` version, `snapshot` dist-tag, no tag or release). A non-forced dispatch is a dry run (build + test only). +5. **GitHub release via `gh release create` in its own job**: `gh release create "$TAG" --verify-tag --generate-notes --notes-start-tag "$PREV" --latest="$IS_LATEST"` with `GITHUB_TOKEN`. + - It runs only if `released == 'true'` and skips if a release for the tag already exists. It publishes directly, not as a draft. + - `previous_tag` (same release line) keeps the notes range correct, and `is_latest` keeps the "Latest" badge on the highest version across parallel lines. +6. **Caller contract: `secrets: inherit` + minimal permissions**: callers pass `secrets: inherit` and declare `permissions: contents: write`. Each job in the release flow narrows to what it needs (`contents: read` for guard, build and e2e; `contents: write` for `github-release`). +7. **Scope and rollout packaging**: + - This ticket covers github-actions only: + - the new workflow + - the tool changes: release mode, force, outputs, snapshot-all + - the `run-many` fix + - removing the install-deps concurrency + - the new caller template, replacing `fe-release.yml` and deleting `fe-tag-pushed.yml` + - README docs for the flow, secrets and force + - These changes land on `master` and on `release/25.4` … `26.4`. + - The consumer rollout follows the pilot (PFM-ISSUE-34287) and excludes cplace-bayer-prompt-fe and the three dormant repos. + +This means: +- A release is a single, ordered run. A failed publish leaves at most a dangling tag; it never leaves a GitHub release without artifacts. +- Recovery is simple: "Re-run failed jobs" on `release` publishes the next patch, a re-run of `github-release` alone reuses the same tag, and a forced dispatch releases even when nothing is affected. +- An accidental "Run workflow" click releases nothing (default `force: false` is a dry run), and dispatch on feature branches or tags is rejected. +- The EPIC-1963 follow-ups below become github-actions-only changes; callers don't change: + - split JFrog secrets instead of `DOT_NPMRC` + - the `GITHUB_TOKEN` tag push (34282) + - retiring `GIT_USER*` (34286) +- Release notes and the "Latest" badge stay correct across parallel release lines (25.4 … 26.4). + +Major trade-offs we're accepting: +1. No resume of a partial publish: a broken version stays partly published and the next run uses a new patch. We give up completing a version, and keep fail-loud behavior on real collisions plus a simple publish path. +2. Forced runs test everything: much slower than an affected run (well beyond the ~1 h cplace-fe cycle). In return, the test scope matches the shipped scope. +3. `secrets: inherit` exposes all caller secrets to the release flow: we give up least-privilege secret passing, and gain auth migrations that don't touch about 120 caller files each time. +4. Removing the install-deps concurrency: superseded PR and main runs are no longer cut short until those callers add run-level concurrency. It costs CI minutes, not correctness. +5. GitHub keeps at most one pending run per concurrency group. A third push while one release is running and one is queued replaces the queued one. The replacing run's affected base is narrower, so its "no new version needed" check may skip a tag that the replaced run would have created. +6. Normalizing caller filenames means the Actions run history starts fresh in repos that don't use template names (cplace-fe, office-reports, workflow-fe, loomeo, legacy-fe-assets, frontend-applications). + +What we're NOT doing (out of scope): +- A `version/*` tag ruleset or a tag-triggered recovery path (Q10, deferred). +- Fixing the branch-off-day `.1` collision (Q9, deferred). +- Skip-if-exists / resumable publish (rejected in research Q4). +- Pushing the tag with `GITHUB_TOKEN` (PFM-ISSUE-34282) or enabling immutable releases (PFM-ISSUE-34284). +- Migrating consumer repos (pilot PFM-ISSUE-34287 and the following rollout). cplace-bayer-prompt-fe and the dormant frontend-applications/-consumer/-documentation-test are excluded from that rollout. +- Retiring `fe-tag.yml` / `fe-release.yml`, adding run-level concurrency to `fe-pr` / `fe-main` templates, and the release-type input (major/minor bump). +- Release branches older than `release/25.4`. + +Success criteria: +- On the live test setup, one push to a `release/*` test branch produces exactly one run that tags, publishes all packages at that version, and creates a GitHub release with correct notes range and "Latest" flag. No second (tag-triggered) run starts. +- A deliberately failed publish leaves a tag without a GitHub release, and "Re-run failed jobs" publishes and releases the next patch. +- A non-forced dispatch tags and publishes nothing. A forced dispatch on a release branch with nothing affected releases. A dispatch on a feature branch fails in `guard`. +- Unit tests cover the new release mode, including tag/push commands, force bypass, `$GITHUB_OUTPUT` values (`released`, `tag`, `previous_tag`, `is_latest`) and the `[branch-off]` precedence over force. + +## Design Decisions - Details + +### Solution shape and job layout + +**Chosen Approach:** One reusable workflow (`fe-release-flow.yml`, variant A1 from research) that nests the existing `fe-install-deps`, `fe-build` and `fe-e2e`, followed by new `release` and `github-release` jobs. A `guard` job comes first and every other job `needs:` it. + +**Rationale:** The order, guards, version outputs and release step are written once in github-actions. Today's callers have drifted: five filename schemes, two broken callers, and the `squad-fe` guard in only three repos. Nesting the existing building blocks keeps one definition of install, build and e2e for later Node, SHA-pin and npmrc changes. + +**Alternatives Considered:** +- **Caller lists individual jobs (A2)**: Rejected in research. It repeats wiring, guards and version plumbing in about 20 repos × 6 branches, and needs `workflow_call` outputs that have no precedent here. +- **Inline install-deps only**: Rejected because the install steps would exist twice and drift. +- **Inline everything**: Rejected because it duplicates about 150 lines of build and e2e matrix shared with fe-pr and fe-main. + +**Implications:** +- The `release` job condition must treat a skipped e2e as success: `needs.build.result == 'success'` and `needs.e2e.result` in (`success`, `skipped`). +- The UI shows two levels of nesting (`release-flow / build / …`). +- One caller file on every migrated branch. On the default branch its `push: release/*` trigger is inert and only dispatch is live. It becomes live when branch-off copies it into a new `release/X.Y`. + +--- + +### Concurrency + +**Chosen Approach:** Concurrency is run-level policy owned by the caller: `group: fe-release-${{ github.ref }}`, `cancel-in-progress: false` in the caller template. The job-level `concurrency:` in `fe-install-deps.yml` is removed completely. + +**Rationale:** A reusable workflow is a fragment of a bigger run, so a group on one nested job only cancels part of that run. In particular, `fe-install-deps-${{ github.ref }}` with `cancel-in-progress: true` would let an ordinary `fe-main` push run cancel a forced dispatch run on the default branch. + +**Alternatives Considered:** +- **Additive opt-out input on `fe-install-deps` (`CANCEL_IN_PROGRESS`)**: Recommended, then overturned on user override. The assumption that turned out wrong: that callers rely on the job-level cancel and that shared-workflow behavior should be preserved. The principle adopted instead is that reusable workflows carry no concurrency. +- **Concurrency inside `fe-release-flow.yml`**: Proposed in the solution overview, then moved to the caller under the same principle. + +**Implications:** +- Every caller must carry the concurrency block correctly. It is defined once in the new caller template. +- `fe-pr` and `fe-main` callers lose the partial cancel of superseded runs until they add their own run-level concurrency (follow-up, out of scope). +- The pending-run replacement behavior is accepted (trade-off 5). + +--- + +### Artifacts tool interface for tag + publish + +**Chosen Approach:** A new combined release mode in `ArtifactsHandler`, selected by a new env flag (plus `FORCE`). In one process it: +1. Computes `calculatedNewVersion` from `git ls-remote --tags`. +2. Applies the existing check `projects.length > 0 || patch === 1`, unless forced. +3. Respects the `[branch-off]` guard, which wins over force. +4. Runs `git tag` + `git push` (PAT from checkout). +5. Publishes all publishable projects at that version. +6. Writes `released`, `tag`, `previous_tag` and `is_latest` to `$GITHUB_OUTPUT`. + +When nothing is tagged, it writes `released=false` and exits successfully. + +**Rationale:** The version never leaves the process, so the tag and the published version can't drift. It matches the Q4 decision that a re-run computes the next version. The existing modes stay byte-for-byte unchanged for unmigrated branches running the same tool. + +**Alternatives Considered:** +- **Two action calls in one job (bump with output, then TAG mode)**: Rejected. It needs a constructor priority fix that changes behavior for every caller of the tool, runs `npm ci`/setup twice, and moves the version through YAML wiring. + +**Implications:** +- The tool also needs a "publish all" switch for forced default-branch snapshots, since today only TAG mode sets `onlyAffected=false`. +- When forced, the tool must skip `nx affected` entirely, because dispatch has no `github.event.before`. +- Logs and the step summary must make clear whether the tag phase or the publish phase failed. +- `previous_tag` = the highest existing tag in the same `X.Y` line (empty for `X.Y.1`). `is_latest` = whether the new tag is the highest `version/*` overall. + +--- + +### Force / `workflow_dispatch` semantics + +**Chosen Approach:** +- `workflow_dispatch` with `force: boolean`, default `false`. +- The `guard` job (first job of `fe-release-flow.yml`): + - **fails** the run on dispatch when the ref is neither `github.event.repository.default_branch` nor `refs/heads/release/*` + - **skips** later jobs when `github.event.pusher.name == 'squad-fe'` +- On dispatch, build, storybook and e2e run on **all** projects. + +| Trigger | Branch | Behavior | +|---|---|---| +| push | `release/*` | affected build/e2e → release if affected → GitHub release | +| dispatch, `force: true` | `release/*` | all build/e2e → release (check bypassed) → GitHub release | +| dispatch, `force: true` | default | all build/e2e → snapshot of all projects, no tag/release | +| dispatch, `force: false` | either | all build/e2e only (dry run) | + +**Rationale:** +- `workflow_dispatch` has no branch filter, and the dropdown offers every branch and tag that contains the caller file. Without the check, a dispatch on a feature branch would publish under the `snapshot` dist-tag, and a dispatch on a `version/*` tag would re-enter TAG mode. +- "Affected since base" describes a push, and a dispatch has none. Since a forced run publishes everything, it tests everything. +- Default `false` as a dry run makes an accidental click harmless, and needs no base anywhere. + +**Alternatives Considered:** +- **Base = parent of the tip commit (tip replay)**: Rejected. It tests roughly nothing when force is used because nothing was affected, which leaves the forced path on less evidence than a normal release. +- **Base = latest version tag of the line**: Rejected. It duplicates the tag lookup in YAML, uses different rules per branch kind, and gives weaker guarantees after a dangling tag. +- **Non-forced dispatch = "release if unreleased" (base = last tag)**: Rejected. It has no meaningful definition on the default branch and needs the same tag-lookup logic. +- **`force` default `true`**: Rejected in favor of the safe default. + +**Implications:** +- `run-many`'s existing "all projects" branch (`affected-projects.ts:27-28`, `ref === ''`) can't be reached today: the action passes `base` and `ref` as positional shell arguments (`run-many/action.yml`), so empty values shift or disappear. + - Workflows keep passing `GHA_REF: ''` for "current ref". The scripts normalize the value: an empty or undefined ref becomes `''`. + - Because of that, `ref === ''` can no longer select "all projects", or every push run would build everything. An explicit "all" input selects it instead. +- The release flow decides "all vs affected" per trigger and passes it to `fe-build` / `fe-e2e`. That means a new optional input on those reusables (additive, default = today's behavior). +- The `force` input description must say that unticked means build and test only. + +--- + +### GitHub release + +**Chosen Approach:** A separate `github-release` job: `needs: release`, `if: needs.release.outputs.released == 'true'`, job-level `permissions: contents: write`, `GH_TOKEN: ${{ github.token }}`. It skips if a release for the tag already exists. Otherwise it runs `gh release create "$TAG" --verify-tag --generate-notes --notes-start-tag "$PREV" --latest="$IS_LATEST"`, omitting `--notes-start-tag` when `previous_tag` is empty. The release is published directly, not as a draft. + +**Rationale:** It has the smallest footprint: `gh` is preinstalled, so there is no new action or SHA pin. `--verify-tag` guarantees the release never creates a tag of its own. As its own job, a release failure never re-triggers publish or burns a version. A draft adds nothing, because the release is created only after a successful publish. + +**Alternatives Considered:** +- **`actions/github-script` + `repos.createRelease`**: Rejected. It means more inline JS, and has no `--verify-tag` equivalent: the API would silently create a missing tag. +- **Create the release inside the artifacts tool**: Rejected. A cosmetic release failure would fail the publish job, and a re-run would publish the next version. +- **GitHub defaults for notes range and "Latest"**: Rejected. With parallel release lines they produce cross-line notes, and a hotfix on an older line would take the "Latest" badge. + +**Implications:** +- Repos with hand-made releases get no special handling (research Q6). The existing-release check makes accidental overlap harmless. +- Once PFM-ISSUE-34284 enables immutability, releases are locked at creation, which is the intended final state. + +--- + +### Secrets and permissions contract + +**Chosen Approach:** Callers use `secrets: inherit` and declare `permissions: contents: write`. `fe-release-flow.yml` declares the secrets it uses: +- JFrog ×3 +- `DOT_NPMRC` +- `GIT_USER_TOKEN`, `GIT_USER`, `GIT_USER_EMAIL` +- optional `PERCY_TOKEN` + +It narrows permissions per job. + +**Rationale:** The secret set changes three times in this epic (split JFrog secrets, 34282, 34286). GitHub rejects a caller that passes a secret the called workflow no longer declares, so explicit passing would force each change into about 20 repos × 6 branches in lockstep. Callers and github-actions share the `collaborationFactory` org, so `inherit` works. + +**Alternatives Considered:** +- **Explicit secrets (current template pattern)**: Rejected. Every auth ticket would mean a coordinated rollout of breaking changes. + +**Implications:** +- Deviates from the existing templates' explicit-secrets pattern. The README must document the required secrets, since the caller no longer shows them. +- The three repos without a `permissions:` block get the template's block when they migrate, so they don't rely on org defaults. + +--- + +### Naming and rollout packaging + +**Chosen Approach:** +- New reusable `fe-release-flow.yml`. +- Consumer caller normalized to `fe-release.yml`: the migration commit deletes both old caller files and adds it. +- The new caller template replaces `workflow-templates/fe/fe-release.yml`. `fe-tag-pushed.yml` is deleted from the templates. +- This ticket delivers github-actions only (all items in decision 7), on `master` and `release/25.4` … `26.4`. +- The consumer rollout follows the pilot PFM-ISSUE-34287 and excludes cplace-bayer-prompt-fe and the three dormant repos. The `RUN_E2E: false` repos (legacy-fe-assets, loomeo, dataciders) migrate with the input set. + +**Rationale:** +- A new name avoids breaking unmigrated callers and ends the tag/publish naming clash. +- Normalized filenames make the following EPIC-1963 rollouts searchable and scriptable, and the migration commit touches these files anyway. +- The pilot is already a separate ticket blocked by this one. + +**Alternatives Considered:** +- **Rework `fe-release.yml` in place**: Rejected. It breaks unmigrated callers on the same ref. +- **Keep per-repo caller filenames**: Rejected. It keeps four drifting naming schemes, in exchange for preserving run history. +- **Migrate all consumers in this ticket**: Rejected. It means about 120 files before the flow is proven, and conflicts with the pilot ticket. + +**Implications:** +- The old reusables `fe-tag.yml` / `fe-release.yml` and their `@release/*` refs stay functional on all github-actions branches during the rollout. +- Run history starts fresh in repos with non-template caller names (trade-off 6). + +--- + +## Overall Architecture + +A thin caller per FE repo branch. It owns the triggers, concurrency, permissions and `secrets: inherit`, and calls `fe-release-flow.yml@`. The reusable workflow owns all release logic. The artifacts tool owns versioning, tagging and publishing in a single process. A final job turns a successful publish into a GitHub release. + +### Key Components +1. **Caller `fe-release.yml` (consumer repos, from the new template)**: `on: push: branches: ['release/*']` and `workflow_dispatch: inputs: force`, run-level non-cancelling concurrency, `permissions: contents: write`, `secrets: inherit`, and inputs such as `RUN_E2E`. +2. **`fe-release-flow.yml` (github-actions, new)**: the jobs `guard`, `install-deps`, `build`, `e2e`, `release` and `github-release`, plus the all-vs-affected decision per trigger. +3. **`fe-install-deps.yml` / `fe-build.yml` / `fe-e2e.yml` (existing)**: reused as-is, except that the install-deps concurrency is removed and build/e2e get an additive "all projects" input. +4. **Artifacts tool (`tools/scripts/artifacts/`)**: a new combined release mode (version → tag → publish → `$GITHUB_OUTPUT`), `FORCE` handling, and snapshot "publish all". +5. **`run-many` (`tools/scripts/run-many/`, `.github/actions/run-many/`)**: robust argument passing and an explicit "all projects" mode. +6. **`github-release` job**: `gh release create --verify-tag` with notes range and the "Latest" flag from tool outputs. +7. **README**: documents the release flow, the required secrets, the caller template, and the semantics of `force` and dry run. + +### Data Flow +1. A push to `release/X.Y` (or a dispatch) starts the caller. The concurrency group serializes runs per branch. +2. `guard` validates the ref and the pusher and decides affected vs all. +3. `install-deps` → `build` ∥ `e2e` run on the checked-out commit. +4. `release` checks out with the PAT. The tool reads remote tags, computes `version/X.Y.Z`, and decides whether to tag (check or force). It pushes the tag, publishes all packages at `X.Y.Z` to JFrog, and emits `released`, `tag`, `previous_tag` and `is_latest`. On the default branch with force, it publishes an all-projects snapshot instead and emits `released=false`. +5. `github-release` runs when `released == 'true'` and creates the release for `tag` using `GITHUB_TOKEN`. + +### Integration Points +- **JFrog npm registry:** publishing via the existing `.npmrc` / `JFROG_*` secrets. +- **GitHub git remote:** tag push via the PAT persisted by `actions/checkout`. +- **GitHub Releases API:** via the `gh` CLI with `GITHUB_TOKEN`. +- **cf-devkit branch-off:** copies the default-branch caller into `release/X.Y` and rewrites `@master` → `@release/X.Y`. The `[branch-off]` guard in the tool still prevents re-releasing branch-off commits. +- **The github-actions upmerge chain:** each change lands on `release/25.4` … `26.4` and `master`, with per-branch refs. + +## Known Risks + +1. **Old tag-push caller left in a migrated tree causes a double publish**: the PAT tag push fires any `on: push: tags` workflow in the tagged commit. - Mitigation: the migration is one commit that deletes both old caller files. The new template's README and the pilot checklist call this out. +2. **Pending-run replacement in the concurrency group**: a replacing run's narrower affected base may skip a needed tag. - Mitigation: accepted; a forced dispatch releases explicitly. +3. **Forced-run duration**: e2e on all projects may exceed the 120-minute `fe-e2e` timeout in large repos. - Mitigation: measure in the live test setup and tune shards or timeout per trigger if needed. +4. **`secrets: inherit` widens secret exposure**: - Mitigation: the reusable workflow lives in our own repo, its refs are pinned to our branches, and changes are reviewed. +5. **`run-many` argument fix touches shared PR/main paths**: - Mitigation: keep today's default behavior and cover the empty-value cases with unit tests. +6. **fe-release was never validated live** (node24 validation log): the publish path has latent issues that appear only at runtime. - Mitigation: a dedicated live test setup before the pilot, with no tests on real `release/*` branches. + +## Next Steps + +1. Review this design document +2. Refine if needed based on feedback +3. Proceed to implementation planning: `/spec-driven-development:create_plan specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md` + +## References + +- Original ticket: [PFM-ISSUE-34283](https://base.cplace.io/pages/3oujt03zx1awhafambsry1q90/PFM-ISSUE-34283-Consolidate-the-FE-2.0-release-flow-into-a-single-workflow) +- Related research: [Research](./research.md) +- Prior rollout pattern: [Node 24 workflow migration design](../2026-06-05_node24-workflow-migration/design.md) +- Existing code: `.github/workflows/fe-tag.yml`, `.github/workflows/fe-release.yml`, `.github/workflows/fe-install-deps.yml`, `.github/workflow-templates/fe/fe-release.yml`, `tools/scripts/artifacts/artifacts-handler.ts`, `tools/scripts/run-many/affected-projects.ts`, `.github/actions/run-many/action.yml` diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md new file mode 100644 index 00000000..ef432233 --- /dev/null +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md @@ -0,0 +1,821 @@ +# Consolidate the FE 2.0 Release Flow into a Single Workflow - Implementation Plan + +## Overview + +Build the github-actions side of PFM-ISSUE-34283 as designed in [design.md](./design.md). The work adds a new reusable workflow, `fe-release-flow.yml`, which runs guard → install-deps → build ∥ e2e → release (version → tag → publish) → GitHub release in one run. It also adds: +- a release-flow mode with `FORCE` and `$GITHUB_OUTPUT` values in the artifacts tool +- an explicit "all projects" mode in `run-many` +- removal of the cancelling install-deps concurrency +- a new caller template that replaces the two old ones +- README documentation + +The work is implemented on `release/25.4` and upmerged through `26.1 → 26.2 → 26.3 → 26.4 → master`, with each step rewriting refs and Node versions for its branch. + +## Current State Analysis + +Verified on HEAD `faa4771` (= `origin/master`) and on `origin/release/25.4` (`e3b9cad`): + +- The release is two runs. The `fe-release.yml` template calls `fe-tag.yml` on push to `release/*`, and the PAT tag push then fires `fe-tag-pushed.yml`, which calls `workflows/fe-release.yml`. +- `ArtifactsHandler` selects a mode from env flags (`artifacts-handler.ts:38-98`). `bumpVersionForReleaseBranch()` (`:187-207` on master, `:174-194` on 25.4) computes the version, applies the `projects.length > 0 || patch === 1` check, and runs `git tag` + `git push`. Nothing is written to `$GITHUB_OUTPUT`. +- Branch drift that matters to this plan: + + | Branch | Node in workflows | `[branch-off]` guard (`Utils.isBranchOffCommit`) | Internal refs | + |---|---|---|---| + | `release/25.4`, `26.1`, `26.2`, `26.3` | `22.15.0` | **absent** | `@release/` | + | `release/26.4` | `24.20.0` | present (`artifacts-handler.ts:109-114`) | `@release/26.4` | + | `master` | `24.20.0` | present | `@master` | + + The guard is deliberately **not** backported. Branch-off only happens from master, so older lines never receive `[branch-off]` commits. The release-flow mode inherits the guard when it upmerges into 26.4, because the guard runs at the top of `handle()` before any mode is selected. +- The upmerge chain is clean today: no commits are pending on any of `25.4→26.1→26.2→26.3→26.4→master`. Upmerges are local merge commits that the user pushes, with no PRs. +- Jest baseline on Windows: 5 tests fail on both `release/25.4` and `master` because of path separators. These failures are unrelated to this work: + - `isE2eAppWithPublicApi returns true when e2e app has public_api.ts` + - `isE2eAppWithPublicApi returns false when e2e app does not have public_api.ts` + - `getAllNxProjects includes e2e apps with public_api.ts` + - `NxProject can provide jfrog Url` + - `e2e app is not publishable when public_api.ts does not exist` +- `actionlint` is not installed locally. The repo CI (`pr-checks.yml`) runs neither jest nor actionlint. + +## Desired End State + +On `release/25.4` … `release/26.4` and `master`: +- `.github/workflows/fe-release-flow.yml` exists, with refs and Node version matching its branch. +- The artifacts tool has a `RELEASE_FLOW` mode, and the composite action exposes `released`, `tag`, `previous_tag` and `is_latest`. +- `run-many` has an explicit `all` input, and `fe-build` / `fe-e2e` have `ALL_PROJECTS`. +- `fe-install-deps.yml` has no `concurrency:`. +- `workflow-templates/fe/fe-release.yml` is the new caller, and `workflow-templates/fe/fe-tag-pushed.yml` is gone. +- The README documents the flow. + +`fe-tag.yml` and `workflows/fe-release.yml` are unchanged everywhere. + +Verify with the per-phase greps below, `npx jest` (no failures beyond the Windows baseline), and `actionlint` over `.github/workflows/` and `.github/workflow-templates/fe/`. + +### Key Discoveries: +- **An empty `GHA_REF` is handled in the scripts, not the workflows.** + - All push callers pass `GHA_REF: ''` (`workflow-templates/fe/fe-main.yml:22`, `fe-release.yml:21`), and they keep doing so. + - `run-many/action.yml:24` passes the arguments positionally and unquoted, so the empty ref arrives as `undefined` and `ref === ''` (`affected-projects.ts:28`) is unreachable today. + - The scripts normalize an empty or undefined ref to `''`. Once they do, `ref === ''` would be true for every push run, so it can't stay the "all projects" switch. "All" becomes an explicit input passed by env. +- **The composite action has to declare outputs.** `$GITHUB_OUTPUT` inside a composite action's `run` step belongs to that step. `.github/actions/artifacts/action.yml` needs an `id` on the ts-node step and an `outputs:` block, or the job can't read the values. +- **`calculateNewVersion` mutates the tag object it returns** (`utils.ts:205`, `latestExistingTag.patchVersion(...)`). That object also sits in the tag list. `previous_tag` therefore has to be read as a string *before* the bump. +- **The release job needs `!cancelled()` for the skipped-e2e case.** A job whose `if:` has no status function gets an implicit `success()`, so a skipped `needs` job (e2e with `RUN_E2E: false`) skips the dependent job. The `release` condition must start with `!cancelled()` and check `needs.*.result` explicitly. +- **Nested reusable workflows need full paths.** Inside a reusable workflow, `./.github/workflows/x.yml` resolves to the caller's repo, so nested calls must use `collaborationFactory/github-actions/.github/workflows/x.yml@`. +- **A new file merges silently.** `fe-release-flow.yml` produces no merge conflict on upmerge, so its `@release/25.4` refs and Node `22.15.0` would carry over unnoticed. Each upmerge rewrites them before committing the merge. +- **Test pattern:** `tools/scripts/artifacts/artifacts-handler-tag.test.ts`. It uses jest `spyOn` on `child_process.execSync` and `Utils` statics, and resets env in `afterEach`. Jest workers reuse processes across files, so new env flags must be reset in `afterEach`. +- **Release job pattern:** `workflows/fe-release.yml:19-54` (checkout, node, cache, use-npmrc, install, artifacts) plus `fe-tag.yml:30-48` (PAT checkout, git identity). + +## What We're NOT Doing + +- Backporting the `[branch-off]` guard (#180) to `release/25.4` … `26.3`. +- A `version/*` tag ruleset or a tag-triggered recovery path. Fixing the branch-off-day `.1` collision. Skip-if-exists / resumable publish. +- Pushing the tag with `GITHUB_TOKEN` (34282), enabling immutable releases (34284), or retiring `GIT_USER*` (34286). +- Changing `fe-tag.yml` / `workflows/fe-release.yml`, or adding run-level concurrency to the `fe-pr` / `fe-main` templates. +- Migrating any consumer repo (pilot PFM-ISSUE-34287 and the following rollout). +- Release branches older than `release/25.4`. +- Scripted live validation. It is deferred and done manually by the user (see "Manual Testing Steps"). + +## Implementation Approach + +Work happens on `improvement/PFM-ISSUE-34283-Consolidate-the-FE-2-0-release-flow-into-a`, branched from `origin/release/25.4`. + +The building blocks come first: `run-many` "all", then the tool's release-flow mode and its composite outputs. Each is unit-tested against the contract the design fixes. Phase 3 wires them together end to end in `fe-release-flow.yml` and the caller template, and checks the wiring statically with actionlint: every `needs.*.outputs.*`, input and secret reference must resolve. Phase 4 documents the flow. Phase 5 opens the PR into `release/25.4`. Phase 6 carries the change up the chain, adapting refs and Node per branch, and adds the `[branch-off]`-over-force test where the guard exists. + +Every push, PR and merge to a shared branch is **[OUTWARD]**: the user approves each one explicitly, and the user executes each push. + +--- + +## Phase 1: Branch Setup and `run-many` "All Projects" Mode + +### Overview +Create the feature branch from `release/25.4`, install actionlint, and commit the spec documents. Then make the existing "all projects" path in `run-many` reachable through an explicit input, with no change to today's default behavior. `fe-build` and `fe-e2e` get an additive `ALL_PROJECTS` input. + +### Changes Required: + +#### 1. Branch and tooling +```bash +git fetch origin +git switch -c improvement/PFM-ISSUE-34283-Consolidate-the-FE-2-0-release-flow-into-a origin/release/25.4 +# the untracked specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/ comes along +npm ci +go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 +actionlint .github/workflows/*.yml # record baseline findings (pre-existing, not ours) +``` +Commit the spec directory as the first commit (`PFM-ISSUE-34283 - Add research, design and plan`). + +#### 2. `run-many` logic +**File**: `tools/scripts/run-many/affected-projects.ts` +**Changes**: +- Replace the `ref === ''` condition with an explicit `all` parameter. `ref` stays in the signature, because `run-many.ts` logs it. +- Add `normalizeRef`, which turns an empty or undefined ref (the workflows pass `GHA_REF: ''`) into `''`. It is exported so it can be unit-tested; `run-many.ts` runs `main()` on import and can't be. + +```ts +export function normalizeRef(ref: string | undefined): string { + return ref ?? ''; +} +``` + +```ts +export function getAffectedProjects( + target: string, + jobIndex: number, + jobCount: number, + base: string, + ref: string, + all = false +) { + let allAffectedProjects = []; + if (all) { + allAffectedProjects = Utils.getAllProjects(false, null, target); + } else { + allAffectedProjects = Utils.getAllProjects(true, base, target); + } + // … unchanged +} +``` + +**File**: `tools/scripts/run-many/run-many.ts` +**Changes**: Normalize the ref, read `RUN_MANY_ALL` from env, log it with the other inputs, and pass both on: +```ts +const ref = normalizeRef(process.argv[6]); // replaces `const ref = process.argv[6];` +const all = process.env.RUN_MANY_ALL === 'true'; +// add `,\n all ${all}` to the existing Inputs log line +const projects = getAffectedProjects(target, jobIndex, jobCount, base, ref, all); +``` + +#### 3. Composite action input +**File**: `.github/actions/run-many/action.yml` +**Changes**: Add an optional input. Pass it by env, so the positional arguments stay byte-identical. +```yaml + all: + description: 'Run the target for all projects instead of the affected ones' + required: false + default: 'false' +# … + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + RUN_MANY_ALL: ${{ inputs.all }} +``` + +#### 4. Reusable build and e2e inputs +**Files**: `.github/workflows/fe-build.yml`, `.github/workflows/fe-e2e.yml` +**Changes**: Add the additive input. The default keeps today's behavior. +```yaml + ALL_PROJECTS: + type: boolean + required: false + default: false +``` +- Pass `all: ${{ inputs.ALL_PROJECTS }}` to the `run-many` step. +- Add `if: ${{ !inputs.ALL_PROJECTS }}` to the "Fetch base branch" step. A dispatch has no base, so `git fetch origin :` would otherwise run against an empty refspec. + +#### 5. Tests (new file: sample first) +**File**: `tools/scripts/run-many/affected-projects.test.ts` (new) +**Step 5a.** Write only the sample test, then stop for review of the pattern: +```ts +import { expect } from '@jest/globals'; +import { getAffectedProjects } from './affected-projects'; +import { Utils } from '../artifacts/utils'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('all=true lists all projects with the target, ignoring base and ref', () => { + const getAllProjects = jest + .spyOn(Utils, 'getAllProjects') + .mockReturnValue(['app-a', 'lib-b', 'lib-c']); + + const projects = getAffectedProjects('build', 1, 1, 'origin/release/26.4', '', true); + + expect(getAllProjects).toHaveBeenCalledWith(false, null, 'build'); + expect(projects).toBe('app-a,lib-b,lib-c'); +}); +``` +**Step 5b** (after the pattern is approved): +- `normalizeRef(undefined)` and `normalizeRef('')` both return `''`, and `normalizeRef('feature/x')` returns `'feature/x'`. +- `all` omitted with `ref === ''` → `getAllProjects(true, base, target)`. This locks in today's effective behavior: a normalized empty ref still means affected. +- `all=false` with a non-empty ref → affected. +- Job slicing: 5 projects, `jobCount` 2 → index 1 gets the 1st, 3rd and 5th projects in sorted order; index 2 gets the 2nd and 4th. + +### Success Criteria: + +#### Automated Verification: +- [ ] The branch is based on `origin/release/25.4`: `git merge-base --is-ancestor origin/release/25.4 HEAD` +- [ ] The new tests pass: `npx jest tools/scripts/run-many` +- [ ] The full suite fails only in the 5 Windows baseline tests: `npx jest 2>&1 | grep -E '^\s+● ' | grep -v Console | sort -u` lists exactly those 5 names +- [ ] The positional args are unchanged: `grep -c 'run-many.ts" ${{ inputs.target }} ${{ inputs.jobIndex }} ${{ inputs.jobCount }} ${{ inputs.base }} ${{ inputs.ref }}$' .github/actions/run-many/action.yml` = 1 +- [ ] `ALL_PROJECTS` is wired: `grep -c 'all: ${{ inputs.ALL_PROJECTS }}' .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` = 1 per file +- [ ] actionlint reports no new findings for `fe-build.yml` / `fe-e2e.yml` compared with the baseline +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 1 complete. Summary: branch created off release/25.4, specs committed, actionlint installed; run-many has an explicit `all` input (env RUN_MANY_ALL), fe-build/fe-e2e have ALL_PROJECTS; affected-projects tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 2." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] The sample test pattern was approved before the remaining tests were written +- [ ] Review: no existing caller passes `all`, so PR, main and release runs keep affected behavior + +--- + +## Phase 2: Artifacts Tool Release-Flow Mode + +### Overview +Add the combined release mode (version → check → tag + push → publish → outputs) to `ArtifactsHandler`, and expose its outputs through the composite action. The existing `ONLY_BUMP_VERSION`, TAG, snapshot and PR modes stay unchanged. + +### Changes Required: + +#### 1. Output and summary helpers +**File**: `tools/scripts/artifacts/utils.ts` +**Changes**: Add thin wrappers, so tests can spy on them the same way they spy on other `Utils` statics. +```ts +import * as core from '@actions/core'; + +public static writeOutput(name: string, value: string) { + console.log(`Output ${name}=${value}`); + core.setOutput(name, value); +} + +public static appendStepSummary(markdown: string) { + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`); + } +} +``` +Let `getLatestTagForReleaseBranch` and `calculateNewVersion` take an optional, precomputed tag list. Existing callers keep exactly one `ls-remote`, in the same order, so the existing mocks in `artifacts-handler-tag.test.ts` still line up. +```ts +public static getLatestTagForReleaseBranch(branch: string, versionOfBranch: Version, allTags?: Version[]): Version { + … + tags = (allTags ?? this.getAllVersionTagsInAscendingOrder()) + .filter(…) + … +} + +public static calculateNewVersion(branch: string, allTags?: Version[]): Version { + const versionOfBranch = Utils.getVersionFromReleaseBranch(branch); + const latestExistingTag = Utils.getLatestTagForReleaseBranch(branch, versionOfBranch, allTags); + … // unchanged +} +``` + +#### 2. Release-flow mode +**File**: `tools/scripts/artifacts/artifacts-handler.ts` +**Changes**: +- Add the fields `releaseFlow` and `force`, parsed like `onlyBumpVersion` (`JSON.parse`, only when defined). +- Dispatch in `handle()` before the `onlyBumpVersion` branch. +- Add the new method. + +```ts +private releaseFlow = false; +private force = false; +// constructor, next to ONLY_BUMP_VERSION: +if (process.env.RELEASE_FLOW != undefined) + this.releaseFlow = JSON.parse(process.env.RELEASE_FLOW); +if (process.env.FORCE != undefined) this.force = JSON.parse(process.env.FORCE); + +// handle(): +if (this.releaseFlow) { + await this.tagAndPublishRelease(); +} else if (this.onlyBumpVersion && …) { … } else { … } + +private async tagAndPublishRelease() { + Utils.writeOutput('released', 'false'); + if (!this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX)) { + if (!this.force) { + console.log(`Release flow on ${this.currentBranch} without force: nothing is tagged or published`); + return; + } + console.log(`Forced release flow on ${this.currentBranch}: publishing a snapshot of all projects`); + this.onlyAffected = false; + this.initProjects(); + await this.buildAndPublishProjects(); + Utils.appendStepSummary(`Published a snapshot of all projects as \`${this.currentVersion}\` (no tag, no GitHub release)`); + return; + } + + const allTags = Utils.getAllVersionTagsInAscendingOrder(); + // read before calculateNewVersion: it bumps the returned tag object in place + const previous = Utils.getLatestTagForReleaseBranch( + this.currentBranch, Utils.getVersionFromReleaseBranch(this.currentBranch), allTags); + const previousTag = previous.isValid() ? previous.getGitTag() : ''; + this.calculatedNewVersion = Utils.calculateNewVersion(this.currentBranch, allTags); + const isLatest = allTags.every((tag) => !tag.isValid() || this.calculatedNewVersion.compareTo(tag) >= 0); + console.log(`The calculated new tag/version for branch ${this.currentBranch} is ${this.calculatedNewVersion}`); + + if (this.force) { + console.log('Force is set: skipping the affected check'); + } else { + // nx affected needs BASE, which only a push provides + this.initAffectedProjects(); + if (this.projects.length === 0 && this.calculatedNewVersion.patch !== 1) { + console.log('No projects are affected and no new Minor Release Branch was added therefore no new Minor/Patch version is needed'); + return; + } + } + + const gitTag = this.calculatedNewVersion.getGitTag(); + console.log(`Tag phase: pushing ${gitTag}`); + console.log(execSync(`git tag ${gitTag}`).toString()); + console.log(execSync(`git push origin ${gitTag}`).toString()); + Utils.writeOutput('tag', gitTag); + Utils.writeOutput('previous_tag', previousTag); + Utils.writeOutput('is_latest', String(isLatest)); + Utils.appendStepSummary(`Tag phase: pushed \`${gitTag}\`. Publish phase started; if this run failed after this line, the tag is dangling and the next run releases the next patch.`); + + console.log(`Publish phase: publishing all publishable projects as ${this.calculatedNewVersion}`); + this.currentVersion = this.calculatedNewVersion; + this.onlyAffected = false; + this.initProjects(); + await this.buildAndPublishProjects(); + Utils.writeOutput('released', 'true'); + Utils.appendStepSummary(`Publish phase: published all publishable projects as \`${this.calculatedNewVersion}\``); +} +``` +Notes: +- `released=false` is written first and is overwritten only after a full publish. `NxProject.publish()` exits the process on failure, so a failed publish leaves `released=false` and a summary line that names the publish phase. +- `initProjects()` with `onlyAffected=false` builds `NxProject`s from `this.task` / `this.currentVersion` (`RELEASE` / the new version on `release/*`; `MAIN_SNAPSHOT` / unique snapshot version on the default branch). Setting `currentVersion` before `initProjects()` is what makes the publish use the tagged version. +- Comment density matches the surrounding code: comment only the non-obvious lines (the mutation note, the BASE note). + +#### 3. Composite action outputs +**File**: `.github/actions/artifacts/action.yml` +**Changes**: Additive. Callers that ignore outputs are unaffected. +```yaml +outputs: + released: + description: 'true if a version was tagged and published (release flow only)' + value: ${{ steps.handler.outputs.released }} + tag: + description: 'The pushed version tag (release flow only)' + value: ${{ steps.handler.outputs.tag }} + previous_tag: + description: 'The previous tag of the same release line, empty for X.Y.1 (release flow only)' + value: ${{ steps.handler.outputs.previous_tag }} + is_latest: + description: 'true if the pushed tag is the highest version/* tag (release flow only)' + value: ${{ steps.handler.outputs.is_latest }} +runs: + using: 'composite' + steps: + … + - id: handler + run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/artifacts/main.ts" + shell: bash +``` + +#### 4. Tests (new file: sample first) +**File**: `tools/scripts/artifacts/artifacts-handler-release-flow.test.ts` (new) +Follow `artifacts-handler-tag.test.ts`. Mock `execSync` by command, not by call order, so that `git tag` / `git push` can be asserted directly. `afterEach` resets `RELEASE_FLOW`, `FORCE`, `BASE`, `TAG`, `ONLY_BUMP_VERSION` and `PR_NUMBER`. + +**Step 4a.** Write only the sample test, then stop for review of the pattern: +```ts +const lsRemote = [ + 'a1\trefs/tags/version/25.4.9', + 'a2\trefs/tags/version/26.3.5', + 'a3\trefs/tags/version/26.4.1', + 'a4\trefs/tags/version/26.4.2', +].join('\n'); + +test('a push to a release branch with affected projects tags, then publishes all projects at that version', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest.spyOn(Utils, 'getAffectedNxProjects').mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(execMock).toHaveBeenCalledWith('git tag version/26.4.3'); + expect(execMock).toHaveBeenCalledWith('git push origin version/26.4.3'); + expect(allProjects).toHaveBeenCalledWith(TASK.RELEASE, expect.objectContaining({ major: 26, minor: 4, patch: 3 }), '@cplace-frontend-applications'); + expect(pushCallOrder(execMock)).toBeLessThan(allProjects.mock.invocationCallOrder[0]); + expect(output).toHaveBeenCalledWith('tag', 'version/26.4.3'); + expect(output).toHaveBeenCalledWith('previous_tag', 'version/26.4.2'); + expect(output).toHaveBeenCalledWith('is_latest', 'true'); + expect(output).toHaveBeenLastCalledWith('released', 'true'); +}); +``` +`mockGit()` returns `lsRemote` for `git ls-remote --tags` and `''` otherwise. `mockBranch()` mocks the branch, the scope, `getRootDir`, `initGithubActionsFile` and `appendStepSummary`. `pushCallOrder()` returns the `invocationCallOrder` of the `git push` call. + +**Step 4b** (after the pattern is approved): +1. Nothing affected, not forced, `patch > 1` → no `git tag` / `git push`, `getAllNxProjects` not called, the only output is `released=false`. +2. Nothing affected, `FORCE=true` → tags `version/26.4.3` and publishes. `getAffectedNxProjects` is **not** called (no `nx affected` on dispatch). +3. First tag of a line (no `version/26.5.*`), nothing affected, not forced → tags `version/26.5.1`, `previous_tag` is `''`, `is_latest` is `true`. +4. Older line `release/26.3` → tags `version/26.3.6`, `previous_tag=version/26.3.5`, `is_latest=false`. +5. `git push` throws → `handle()` rejects, `getAllNxProjects` is not called, `released` is never `true`. +6. Default branch (`master`), `FORCE=true` → no `git tag` / `git push`. `getAllNxProjects` is called with `TASK.MAIN_SNAPSHOT` and a version whose string contains `-SNAPSHOT-`. `released=false`. +7. Default branch, not forced → `getAllNxProjects` / `getAffectedNxProjects` not called, `released=false`. +8. Unchanged modes: `RELEASE_FLOW` unset → `writeOutput` is never called on the `ONLY_BUMP_VERSION` path (guards against output leaking into the old flow). + +Also add to `utils.test.ts`: `calculateNewVersion(branch, tags)` with a precomputed list performs no `ls-remote`. + +### Success Criteria: + +#### Automated Verification: +- [ ] Release-flow tests pass: `npx jest tools/scripts/artifacts/artifacts-handler-release-flow.test.ts` +- [ ] Existing mode tests pass unchanged: `npx jest tools/scripts/artifacts/artifacts-handler-tag.test.ts tools/scripts/artifacts/artifacts-handler.test.ts` +- [ ] The full suite fails only in the 5 Windows baseline tests (same command as Phase 1) +- [ ] TypeScript compiles: `npx tsc --noEmit -p tsconfig.json` +- [ ] Composite outputs are declared: `grep -c 'steps.handler.outputs' .github/actions/artifacts/action.yml` = 4 +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 2 complete. Summary: ArtifactsHandler has a RELEASE_FLOW mode (version → affected check unless FORCE → git tag/push → publish all at that version → outputs released/tag/previous_tag/is_latest; forced default branch = all-projects snapshot); artifacts action exposes the outputs; release-flow tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 3." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] The sample test pattern was approved before the remaining tests were written +- [ ] Review: `bumpVersionForReleaseBranch()` and the constructor's mode selection are unchanged (`git diff origin/release/25.4 -- tools/scripts/artifacts/artifacts-handler.ts` shows only additions, plus the `handle()` dispatch) + +--- + +## Phase 3: Release-Flow Workflow, Concurrency, Caller Template + +### Overview +Wire everything end to end. This phase adds the new reusable workflow that consumes the Phase 1 and 2 contracts, removes the job-level concurrency from install-deps, replaces the release caller template and deletes the tag-pushed template. actionlint checks that every input, secret, `needs` and output reference resolves. + +### Changes Required: + +#### 1. New reusable workflow +**File**: `.github/workflows/fe-release-flow.yml` (new, `release/25.4` flavor: Node `22.15.0`, refs `@release/25.4`) + +```yaml +name: Frontend Release Flow Workflow + +on: + workflow_call: + inputs: + FORCE: + description: 'release/*: release even if no project is affected. Default branch: publish a snapshot of all projects. false: build and test only.' + type: boolean + required: false + default: false + RUN_E2E: + type: boolean + required: false + default: true + secrets: + JFROG_BASE64_TOKEN: + required: true + JFROG_URL: + required: true + JFROG_USER: + required: true + DOT_NPMRC: + required: true + GIT_USER_TOKEN: + required: true + GIT_USER: + required: true + GIT_USER_EMAIL: + required: true + PERCY_TOKEN: + required: false + +env: + NX_BRANCH: ${{ github.event.number }} + NX_RUN_GROUP: ${{ github.run_id }} + +jobs: + guard: + # bot pushes (upmerge, version commits) never release + if: github.event_name != 'push' || github.event.pusher.name != 'squad-fe' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Check dispatch ref + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + DEFAULT_REF: refs/heads/${{ github.event.repository.default_branch }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$REF" != "$DEFAULT_REF" ] && [[ "$REF" != refs/heads/release/* ]]; then + echo "::error::The release flow can only be dispatched on the default branch or a release/* branch, not on $REF" + exit 1 + fi + + install-deps: + needs: guard + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@release/25.4 + with: + GHA_REF: '' + secrets: + DOT_NPMRC: ${{ secrets.DOT_NPMRC }} + + build: + needs: install-deps + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@release/25.4 + with: + GHA_REF: '' + GHA_BASE: ${{ github.event.before }} + ALL_PROJECTS: ${{ github.event_name == 'workflow_dispatch' }} + + e2e: + needs: install-deps + if: inputs.RUN_E2E + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@release/25.4 + with: + GHA_REF: '' + GHA_BASE: ${{ github.event.before }} + ALL_PROJECTS: ${{ github.event_name == 'workflow_dispatch' }} + secrets: + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + + release: + needs: [build, e2e] + # a skipped e2e (RUN_E2E: false) must not skip the release; a non-forced dispatch is a dry run + if: >- + !cancelled() + && needs.build.result == 'success' + && (needs.e2e.result == 'success' || needs.e2e.result == 'skipped') + && (github.event_name == 'push' || inputs.FORCE) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + previous_tag: ${{ steps.release.outputs.previous_tag }} + is_latest: ${{ steps.release.outputs.is_latest }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: '' + fetch-depth: 0 + token: ${{ secrets.GIT_USER_TOKEN }} + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22.15.0 + - name: Cache Node Modules + id: npm-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} + - name: Use .npmrc + uses: collaborationFactory/github-actions/.github/actions/use-npmrc@release/25.4 + with: + dot-npmrc: ${{ secrets.DOT_NPMRC }} + - name: Install Modules + if: steps.npm-cache.outputs.cache-hit != 'true' + run: npm ci && echo "modules were installed via npm" || yarn + - name: Configure git user + run: | + git config user.name ${{ secrets.GIT_USER }} + git config user.email ${{ secrets.GIT_USER_EMAIL }} + - name: Tag and Push to Jfrog NPM Registry + id: release + uses: collaborationFactory/github-actions/.github/actions/artifacts@release/25.4 + env: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + RELEASE_FLOW: true + FORCE: ${{ inputs.FORCE }} + BASE: ${{ github.event.before }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + + github-release: + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.release.outputs.tag }} + PREVIOUS_TAG: ${{ needs.release.outputs.previous_tag }} + IS_LATEST: ${{ needs.release.outputs.is_latest }} + run: | + if gh release view "$TAG" > /dev/null 2>&1; then + echo "A GitHub release for $TAG already exists, nothing to do" + exit 0 + fi + args=(--verify-tag --generate-notes "--latest=$IS_LATEST") + if [ -n "$PREVIOUS_TAG" ]; then + args+=(--notes-start-tag "$PREVIOUS_TAG") + fi + gh release create "$TAG" "${args[@]}" +``` + +#### 2. Remove the install-deps concurrency +**File**: `.github/workflows/fe-install-deps.yml` +**Changes**: Delete lines 16-18 (`concurrency:` / `group:` / `cancel-in-progress:`). + +#### 3. New caller template +**File**: `.github/workflow-templates/fe/fe-release.yml` (replace the contents) +```yaml +# Replaces the old two-file release (this file as tagger + fe-tag-pushed.yml / on_tag_pushed.yaml / tag_pushed.yml). +# Delete BOTH old caller files in the same commit that adds this one: the release flow still pushes +# the tag with the PAT, so a tag-push workflow left in the tree would publish a second time. +name: 'Frontend Release CI' +on: + push: + branches: + - 'release/*' + workflow_dispatch: + inputs: + force: + description: 'Tag and publish (release/*) or publish a snapshot of all projects (default branch). Unticked: build and test only, nothing is tagged or published.' + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: fe-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-flow: + uses: collaborationFactory/github-actions/.github/workflows/fe-release-flow.yml@release/25.4 + with: + FORCE: ${{ inputs.force == true }} + # RUN_E2E: false # only for repos that do not run e2e before a release + secrets: inherit +``` + +#### 4. Delete the tag-pushed template +**File**: `.github/workflow-templates/fe/fe-tag-pushed.yml` +**Changes**: `git rm`. + +### Success Criteria: + +#### Automated Verification: +- [ ] actionlint is clean for the new and changed files: `actionlint .github/workflows/fe-release-flow.yml .github/workflows/fe-install-deps.yml .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` +- [ ] The caller template lints as a workflow: `actionlint .github/workflow-templates/fe/fe-release.yml` +- [ ] No reusable FE workflow declares concurrency except `fe-licenses.yml`: `grep -l '^\s*concurrency:' .github/workflows/fe-*.yml` = `fe-licenses.yml` only +- [ ] All internal refs in the new files point to this branch: `grep -o 'github-actions/.github/[a-z]*/[a-z-]*\(.yml\)\?@[^ ]*' .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml | grep -vc '@release/25.4$'` = 0 +- [ ] The old reusables are untouched: `git diff --quiet origin/release/25.4 -- .github/workflows/fe-tag.yml .github/workflows/fe-release.yml` +- [ ] The tag-pushed template is gone: `test ! -e .github/workflow-templates/fe/fe-tag-pushed.yml` +- [ ] The full jest suite fails only in the 5 Windows baseline tests +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 3 complete. Summary: fe-release-flow.yml added (guard → install-deps → build ∥ e2e → release → github-release), install-deps concurrency removed, caller template replaced (push release/* + dispatch force, run-level non-cancelling concurrency, secrets: inherit), fe-tag-pushed template deleted; actionlint clean. Please review the changes and reply 'yes' to continue to Phase 4." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] Walk through the trigger table in design.md against the `if:` conditions: push on `release/*`; dispatch with force on `release/*`; dispatch with force on the default branch; dispatch without force; dispatch on a feature branch or tag; `squad-fe` push; `RUN_E2E: false` + +--- + +## Phase 4: README Documentation + +### Overview +Document the release flow for consumers. The caller no longer lists secrets (`secrets: inherit`), so the README is where they are described. + +### Changes Required: + +#### 1. README section +**File**: `README.md` +**Changes**: Add a `## FE Release Flow` section after "Node 24 Compatibility" with these subsections: +- **What it does**: one run per push to `release/*` or dispatch. Order: guard → install-deps → build ∥ e2e → tag + publish (one job) → GitHub release. Tag before publish: a failed tag push retries the same version, and a failed publish moves the next run to the next patch. +- **Caller**: copy `.github/workflow-templates/fe/fe-release.yml`. When migrating a branch, delete the old branch-push and tag-push caller files in the same commit (double-publish warning). The caller file name is `fe-release.yml`. The copy on the default branch is the seed for branch-off. Set `RUN_E2E: false` only for repos without e2e on release. +- **Triggers and `force`**: the four-row trigger table from design.md. A dispatch on any other ref fails in `guard`. `squad-fe` pushes are skipped. +- **Required secrets** (via `secrets: inherit`): `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, `DOT_NPMRC`, `GIT_USER_TOKEN`, `GIT_USER`, `GIT_USER_EMAIL`, optional `PERCY_TOKEN`. Permissions: `contents: write`. +- **Recovery**: + - "Re-run failed jobs" on `release` publishes and releases the next patch, leaving the failed version as a dangling tag. + - Re-running only `github-release` reuses the same tag and skips if the release already exists. + - A forced dispatch releases even when nothing is affected. +- **Concurrency**: one run per branch at a time, non-cancelling. GitHub keeps only the newest pending run. +- **GitHub release**: created with `GITHUB_TOKEN`. The notes range starts at the previous tag of the same line, and "Latest" goes only to the highest `version/*` tag. + +### Success Criteria: + +#### Automated Verification: +- [ ] The section exists: `grep -c '^## FE Release Flow' README.md` = 1 +- [ ] Every secret declared by the workflow is documented: `for s in $(sed -n '/^ secrets:/,/^env:/p' .github/workflows/fe-release-flow.yml | grep -oE '^ [A-Z_]+' | tr -d ' '); do grep -q "$s" README.md || echo "missing $s"; done` prints nothing +- [ ] Prettier is clean on the touched files: `npx prettier --check README.md .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml .github/actions tools/scripts/run-many tools/scripts/artifacts` +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 4 complete. Summary: README has an 'FE Release Flow' section (flow, caller migration incl. double-publish warning, trigger/force table, required secrets, recovery, concurrency, GitHub release). Please review README.md and reply 'yes' to continue to Phase 5." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] The README reads correctly for someone who migrates a consumer repo without having read design.md + +--- + +## Phase 5: Review and PR into `release/25.4` + +### Overview +Self-review the branch, then open the implementation PR against `release/25.4`. The PR must not target master; default detection would guess wrong. + +### Changes Required: + +#### 1. Self-review +Run `/review:branch-merge` (or the `code-review` skill) over `origin/release/25.4..HEAD`. Fix only the findings this change caused. + +#### 2. PR [OUTWARD] +Push the branch and open the PR only after explicit user approval. +- Base: `release/25.4`. Title: `PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow`. +- Body: + - the building blocks (run-many `all`, tool release-flow mode + outputs, `fe-release-flow.yml`, install-deps concurrency removal, new caller template, README) + - the link to PFM-ISSUE-34283 + - the "not in scope" list + - a note that live validation is pending or manual + - a note that the upmerges follow after merge + +### Success Criteria: + +#### Automated Verification: +- [ ] The PR targets `release/25.4`: `gh pr view --json baseRefName --jq .baseRefName` = `release/25.4` +- [ ] The PR's required checks (`pr-checks.yml`) pass: `gh pr checks` +- [ ] The full jest suite fails only in the 5 Windows baseline tests +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 5 complete. Summary: self-review done (findings fixed or listed), PR # open against release/25.4. Please review the PR, and reply 'yes' once it is merged to continue to Phase 6 (upmerge chain)." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] The live validation from "Manual Testing Steps" was done or consciously deferred before merge +- [ ] The PR is reviewed and merged into `release/25.4` + +--- + +## Phase 6: Upmerge Chain `26.1 → 26.2 → 26.3 → 26.4 → master` + +### Overview +Carry the merged change up the chain as local merge commits (the repo's existing upmerge practice). Before committing each merge, rewrite the new file's refs and Node version for the target branch. On `26.4`, where the `[branch-off]` guard exists, resolve `handle()` so the guard runs first and add the test that it wins over force. Every push is **[OUTWARD]** and executed by the user after approval. + +### Changes Required: + +#### 1. Per target branch `T` (from source `S`): `26.1←25.4`, `26.2←26.1`, `26.3←26.2`, `26.4←26.3`, `master←26.4` +```bash +git fetch origin +git switch -C release/T origin/release/T # for master: git switch -C master origin/master +git merge --no-commit origin/release/S +``` +Resolve before committing: +- **Refs**: + - in `.github/workflows/fe-release-flow.yml` and `.github/workflow-templates/fe/fe-release.yml`, rewrite `@release/S` → `@release/T` (`@master` for master) + - check that the other files' refs stayed `@release/T` / `@master` +- **Node**: set `node-version:` in `fe-release-flow.yml` to the target's value (`22.15.0` for 26.1–26.3, `24.20.0` for 26.4/master). Take it from `.github/workflows/fe-release.yml` on `T`. +- **Conflicts**: + - `fe-build.yml` / `fe-e2e.yml` / `fe-install-deps.yml`: keep the target's Node, image and pins, plus this change's additions or removal. + - `fe-tag-pushed.yml` template: modified on `T` (refs) and deleted on `S`. Resolve as deleted. + - Caller template: take `S`'s content with refs for `T`. +- **On `26.4` (and again on master)**, `artifacts-handler.ts` `handle()`: keep the `[branch-off]` guard block first, then the `releaseFlow` / `onlyBumpVersion` / publish dispatch. In `artifacts-handler-release-flow.test.ts`, add: + - `[branch-off] tip commit with RELEASE_FLOW and FORCE → no git tag, no git push, no getAllNxProjects`. Mock `Utils.isBranchOffCommit` → `true`. + - Make `mockBranch()` mock `isBranchOffCommit` → `false` for all other tests, as `artifacts-handler-tag.test.ts` does on these branches. + - On master, the test arrives through the merge from 26.4. + +Then commit the merge with git's default message (`Merge remote-tracking branch 'origin/release/S' into release/T`) and push after approval. + +### Success Criteria: + +#### Automated Verification (on each target branch after its merge): +- [ ] No ref from another branch remains in the new files: `grep -o 'github-actions/.github/[a-z]*/[a-z-]*\(.yml\)\?@[^ ]*' .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml | grep -vc '@$'` = 0 +- [ ] The Node version matches the branch: `diff <(grep -o 'node-version: .*' .github/workflows/fe-release-flow.yml) <(grep -o 'node-version: .*' .github/workflows/fe-release.yml)` is empty +- [ ] The whole branch uses one internal ref: `grep -rhoE 'collaborationFactory/github-actions/\.github/[^@]+@[^ ]+' .github | sed 's/.*@//' | sort -u` prints only `release/T` (or `master`) +- [ ] The full jest suite fails only in the 5 Windows baseline tests (on 26.4/master, the branch-off test passes too: `npx jest -t 'branch-off'`) +- [ ] actionlint is clean for `fe-release-flow.yml`, `fe-build.yml`, `fe-e2e.yml`, `fe-install-deps.yml` and the caller template +- [ ] The chain is complete at the end: `git rev-list --count origin/master..origin/release/26.4` = 0, and the same for each adjacent pair +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 6 complete. Summary: upmerged 25.4 → 26.1 → 26.2 → 26.3 → 26.4 → master; per-branch refs and Node versions verified; [branch-off]-over-force test added on 26.4 and passing on 26.4 and master. Please review the merges and reply 'yes' to close out the plan." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. + +#### Manual Verification: +- [ ] Each push was approved and executed by the user +- [ ] On master, the caller template references `@master` (the seed that branch-off rewrites to `@release/X.Y`) + +--- + +## Testing Strategy + +### Unit Tests: +- `affected-projects.test.ts`: explicit `all`, ref normalization (empty or undefined → `''`), the unchanged default when `ref === ''`, and job slicing. +- `artifacts-handler-release-flow.test.ts`: + - tag/push commands, and the version passed to publish + - publish happens after the push + - the affected check, force bypass and skipped `nx affected` + - first tag of a line, `previous_tag` / `is_latest` across lines + - push failure, forced and non-forced default branch + - no outputs from the old modes + - on 26.4/master, `[branch-off]` wins over force +- `utils.test.ts`: a precomputed tag list avoids a second `ls-remote`. +- The existing `artifacts-handler-tag.test.ts` / `artifacts-handler.test.ts` pass unchanged (regression for unmigrated callers). + +### Integration Tests: +- actionlint over the new workflow, the changed reusables and the caller template. It checks `needs` / outputs / inputs / secrets wiring. +- No automated live run (deferred; see below). + +### Manual Testing Steps (live validation, done by the user before merging into `release/25.4` or later): +1. Push a `test/*` branch of github-actions whose internal refs point to `@test/…`, and a caller from the new template on a numeric throwaway release line (e.g. `release/99.1`) in the test repo. Never use a real `release/*` line. +2. One push → exactly one run: + - it tags `version/99.1.1` and publishes all packages at `99.1.1` (dist-tag `release-99.1`) + - the GitHub release has the right notes range and "Latest" flag + - no tag-triggered run starts +3. Make the publish fail on purpose → a tag without a GitHub release. "Re-run failed jobs" publishes and releases `99.1.2`. +4. A dispatch without force → build and test only. A dispatch with force and nothing affected → releases. A dispatch on a feature branch → fails in `guard`. +5. Clean up: the test tags, GitHub releases and JFrog versions, and the test branches. + +## Performance Considerations + +- Forced and dispatched runs build, storybook and e2e **all** projects, which is much slower than an affected run. Watch the 120-minute `fe-e2e` timeout in large repos during the pilot (design risk 3). It isn't tuned in this plan. +- Removing the install-deps concurrency means superseded PR and main runs are no longer cut short. This costs CI minutes, not correctness (design trade-off 4). + +## Migration Notes + +- Unmigrated consumer branches keep the old two-run flow. `fe-tag.yml` / `workflows/fe-release.yml` stay functional on every github-actions branch. +- A consumer branch migrates with one commit: add `.github/workflows/fe-release.yml` from the template (with the ref for that branch), and delete both old caller files. That is the pilot PFM-ISSUE-34287 and the rollout after it. Both are out of scope here. +- Rollback: revert the merge on the affected github-actions branch. Unmigrated callers are unaffected either way. Migrated callers would need their old caller pair restored. + +## References + +- Research: [Research Document](./research.md) +- Design: [Design Document](./design.md) +- Original ticket: [PFM-ISSUE-34283](https://base.cplace.io/pages/3oujt03zx1awhafambsry1q90/PFM-ISSUE-34283-Consolidate-the-FE-2.0-release-flow-into-a-single-workflow) +- Test pattern: `tools/scripts/artifacts/artifacts-handler-tag.test.ts:1-114` +- Release job pattern: `.github/workflows/fe-release.yml:19-54`, `.github/workflows/fe-tag.yml:26-58` +- Tag logic to mirror: `tools/scripts/artifacts/artifacts-handler.ts:187-207` (master) / `:174-194` (25.4) +- Prior rollout plan: `specs/2026-06-05_node24-workflow-migration/plan.md` diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/research.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/research.md new file mode 100644 index 00000000..e796466d --- /dev/null +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/research.md @@ -0,0 +1,405 @@ +--- +date: 2026-10-12T15:38:13+02:00 +git_commit: faa477146b7039659f88a128b9b364f64b1eddae +branch: master +topic: "Consolidate the FE 2.0 release flow (fe-tag + fe-release) into a single workflow" +tags: [research, github-actions, fe-release, fe-tag, artifacts, versioning, jfrog, github-release, immutable-releases, fe-repos] +status: complete +last_updated: 2026-10-13 +last_updated_note: "Recorded decisions on open questions (Q2 single reusable workflow, Q4 re-run publishes next version, rollout release/25.4+); earlier: force input follow-up, corrected tag-triggered workflow reasoning" +--- + +# Research: Consolidate the FE 2.0 release flow into a single workflow + +**Date**: 2026-10-12T15:38:13+02:00 +**Git Commit**: faa477146b7039659f88a128b9b364f64b1eddae (collaborationFactory/github-actions) +**Branch**: master + +## Research Question +PFM-ISSUE-34283: how does the FE 2.0 release flow work today across `collaborationFactory/github-actions` (the reusable FE workflows/actions) and all FE repos that consume it, and what needs to change to turn it into a single, deadlock-free workflow (build + test → push version tag → publish to JFrog → finalize immutable GitHub release)? + +## PFM Ticket Context +- **Ticket**: [PFM-ISSUE-34283](https://base.cplace.io/pages/3oujt03zx1awhafambsry1q90/PFM-ISSUE-34283-Consolidate-the-FE-2.0-release-flow-into-a-single-workflow) +- **Title**: Consolidate the FE 2.0 release flow into a single workflow +- **Type**: PFM-ISSUE (30.groundwork), priority 30.medium, topic GitHub, squad 01 - Build & Connect / Stratum +- **Status**: 50.working (started 2026-10-12) +- **Target Release**: not set (planned date 2026-07-27) +- **Assignee**: Vladimir Arsov +- **Epic**: [EPIC-1963 Modernize FE 2.0 Frontend Workflow Authentication](https://base.cplace.io/pages/bzqjct9gt505sq7dm2hgu11m0/EPIC-1963-Modernize-FE-2.0-Frontend-Workflow-Authentication) +- **Relations**: relates to [PFM-ISSUE-33676](https://base.cplace.io/pages/w64364g0a1qsl6z6vb11ngevs/PFM-ISSUE-33676-Replace-shared-GitHub-user-account-in-frontend-workflows); blocks PFM-ISSUE-34282 (GITHUB_TOKEN for in-repo tag and release), PFM-ISSUE-34284 (enable GitHub release immutability on FE 2.0 repos), PFM-ISSUE-34286 (retire shared GitHub user), PFM-ISSUE-34287 (pilot on cplace-remote-filesystem-fe) +- **Suggested Branch**: `improvement/PFM-ISSUE-34283-Consolidate-the-FE-2-0-release-flow-into-a` +- **Description**: + + > **Description** — Restructure the FE 2.0 release into a **single workflow** with a deadlock-free order. This can be done **first**, under the current authentication setup, ahead of the `GITHUB_TOKEN` switch. + > + > **Requirements** + > - One workflow, triggered by a push event and/or a manual `workflow_dispatch` with a user-selectable input (e.g. release type); **manual tag creation is disabled**. + > - Order within the run: **build + test → push the version tag → publish artifacts to JFrog → finalize the (immutable) GitHub release**. + > - Keep tag-before-publish: the version is derived from the last git tag, so a failed tag step retries with the **same** version and a failed publish advances the next run to the **next** version — avoiding publish collisions. + > - Finalize the immutable GitHub release only **after** a successful publish, so a failed publish leaves at most a skippable dangling tag. + > + > **Acceptance Criteria** + > - The release runs as a single workflow with the specified ordering. + > - Manual tag creation no longer triggers a release. + > - Retries are deadlock-free; a failed publish never leaves an immutable release without an artifact. + > + > **Dependencies** — Can land first (works with the current authentication setup); precedes the `GITHUB_TOKEN` switch. Relates to PFM-ISSUE-33676. + +### Epic / related ticket context (summarised) +- **EPIC-1963** modernises FE 2.0 workflow auth: (a) JFrog npm read/publish via separate secrets instead of a stored `.npmrc` (`DOT_NPMRC`), (b) replace the shared GitHub user (`GIT_USER*`, actor `squad-fe`) used for tag creation, PR decoration and cross-repo checkout with `GITHUB_TOKEN`/GitHub App. Out of scope for the epic: broader workflow restructuring — 34283 is the one sanctioned restructuring, as a prerequisite. +- **PFM-ISSUE-33676** maps each shared-user usage to `GITHUB_TOKEN` (likely tag creation, PR decoration) or a GitHub App (cross-repo e2e checkout). +- Epic attachment `PRD-FE2.0-Build-Integration.md` could not be read via MCP (no content returned). + +## Summary +- **Today the release is two chained workflow runs in every FE repo.** + 1. A caller workflow on `push: branches: ['release/*']` runs `fe-install-deps` → (`fe-build` ∥ `fe-e2e`) → `fe-tag`. `fe-tag` computes the next `version/X.Y.Z` and pushes a lightweight tag using the PAT `GIT_USER_TOKEN` (actor `squad-fe`). + 2. That tag push triggers a second caller workflow on `push: tags: ['*/*']`, which calls the reusable `fe-release.yml` with `TAG=github.ref_name` to build all publishable projects and `npm publish` them to JFrog. + - The PAT is required *only* because a tag pushed with `GITHUB_TOKEN` does not trigger other workflows. An earlier attempt to drop the token (d6542fe, 2023-11-03) was reverted three days later (a416ad2). +- **The two runs are not ordered or guarded together.** + - Any `*/*` tag (manual, UI-created release, stray tags) starts a full publish. + - A failed publish leaves a tag with no or partial artifacts. + - On a re-run, a partial publish fails on the first already-published package (E403 "cannot publish over previously published versions"). This was observed live, e.g. `version/26.4.1` and `version/26.3.1` on branch-off day, and `version/26.1.55`. +- **No GitHub Release is created anywhere.** + - There is no `gh release`/octokit code, and immutable releases are disabled (`cplace-fe`: `{"enabled":false}`). + - No tag rulesets protect `version/*`. + - Five repos have hand-made GitHub releases whose tag creation also triggered publishes. + - The "finalize immutable release" step is entirely new. +- **Version derivation already matches the ticket's tag-before-publish model.** + - The next version is computed from remote tags (`git ls-remote --tags`): the highest `version/..*` for the `release/.` branch, + 1 patch, or `X.Y.1` if none exists. + - So a failed tag step recomputes the same version, and a pushed-but-unpublished tag makes the next run use the next patch. + - There is no notion of release type: major/minor come from the branch name, and only the patch increments. +- **The consolidation's main technical gaps are:** + - Passing the computed version between jobs. In one run `github.ref_name` is the branch, not the tag, and the artifacts tool writes no `$GITHUB_OUTPUT`. + - Handling "no new version needed" (no tag → nothing downstream). + - Making publish safe to re-run. + - Adding a non-cancelling concurrency group. + - Creating and finalizing the GitHub release. + - Avoiding a double publish on each branch that gets migrated. A tag push runs the `on: push: tags` workflows found in the tagged commit. When a branch moves to the new workflow, its migration commit must therefore replace both old files (branch-push and tag-push). If the old tag workflow stays in that tree, the new workflow's PAT tag push would still fire it and publish twice. Branches that are not migrated keep the old two-run flow unchanged and need no edits. + - Enforcing "manual tag creation no longer triggers a release". Removing workflow files can't fully do this, because a manually created tag on an older commit still finds the old workflow there. A `version/*` tag ruleset that restricts creation to the release actor enforces it on every branch. No tag rulesets exist today. +- **The rollout is broad.** + - 23 caller repos have several file-name variants. + - Every live `release/YY.N` branch in both `github-actions` and the consumers pins `@release/YY.N`. cf-devkit branch-off rewrites `@master` → `@release/X.Y`. + +## Detailed Findings + +### 1. Reusable workflows in github-actions involved in the release +- **`.github/workflows/fe-tag.yml`** (workflow_call) + - Inputs: `GHA_REF`, `GHA_BASE`. + - Required secrets: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, `GIT_USER_TOKEN`, `GIT_USER_EMAIL`, `GIT_USER` (lines 12-24). + - Job `tag`: + - Checkout with `token: secrets.GIT_USER_TOKEN`, `fetch-depth: 0` (30-34). The persisted PAT is what authenticates the later `git push`. + - setup-node 24.20.0, plus the node_modules cache only. There is no `npm ci` and no `use-npmrc`. + - `git config user.name/email` from `GIT_USER`/`GIT_USER_EMAIL` (45-48). + - `artifacts@master` with `ONLY_BUMP_VERSION: true`, `BASE: inputs.GHA_BASE`, `DEFAULT_BRANCH` (50-58). +- **`.github/workflows/fe-release.yml`** (workflow_call, no inputs) + - Secrets: JFrog ×3 and `DOT_NPMRC`. + - Workflow env: `NX_BRANCH`, `NX_RUN_GROUP` (15-17). + - Job `release-version`: + - Checkout `ref: ''`, which resolves to the tag in a tag-push context (23-26). + - setup-node, cache, `use-npmrc@master`, then `npm ci || yarn` on a cache miss (43-46). + - `artifacts@master` with `TAG: ${{ github.ref_name }}` (48-54). +- **Build/test building blocks**, usable from push or dispatch contexts: + - `fe-install-deps.yml`: input `GHA_REF`, secret `DOT_NPMRC`. + - Job-level `concurrency: fe-install-deps-${{github.ref}}`, `cancel-in-progress: true` (16-18). This would cancel an in-flight release on the same branch. + - `fe-build.yml`: `GHA_REF`, `GHA_BASE`; matrix build/storybook × 2 shards via `run-many`. + - `fe-e2e.yml`: `GHA_REF`, `GHA_BASE`, optional `PERCY_TOKEN`; Cypress container, 4 shards. +- **Not reusable for a push/dispatch release as-is:** + - `fe-code-quality.yml` and `fe-licenses.yml` read `github.event.pull_request.*` and are PR-bound. + - `fe-sonar.yml` is push-capable but not sharded. +- **Outputs and permissions:** no reusable workflow declares `on.workflow_call.outputs`. The only job output is `check-sonar.outputs.has_token` in `fe-code-quality.yml:95-96`. No reusable workflow sets `permissions:` except `fe-sonar.yml:36-37` (read) and `pr-checks.yml:16-17`. +- **Concurrency:** none on tag, release or publish. + +### 2. Caller templates in github-actions +- `.github/workflow-templates/fe/fe-release.yml` (branch-triggered) runs install-deps → e2e-tests ∥ build → `tag` (`needs: [build, e2e-tests]`, lines 41-53). + - It passes `GHA_REF: ''`, `GHA_BASE: ${{ github.event.before }}` and the `GIT_USER*` secrets. + - Name clash: this template's `fe-release.yml` is the tagging workflow, while the reusable `workflows/fe-release.yml` is the publishing workflow. +- `.github/workflow-templates/fe/fe-tag-pushed.yml` triggers on `push: tags: ['*/*']` (3-5) and calls `fe-release.yml`. +- Templates declare `permissions: contents: write, pull-requests: write, actions: read, checks: write` (e.g. `fe-tag-pushed.yml:7-11`). +- Templates are only copied into repos manually, so editing them doesn't update existing callers. +- `README.md` documents only "Node 24 Compatibility". There is no doc of the release flow, wiring or secrets. + +### 3. The `artifacts` action and versioning logic (`tools/scripts/artifacts/`) +- `.github/actions/artifacts/action.yml:6-10` runs `printenv`, then `npm ci` (in the actions repo), then `ts-node tools/scripts/artifacts/main.ts`. `main.ts:3` is `new ArtifactsHandler().handle()` with no catch. +- **Mode selection** (`artifacts-handler.ts:38-98`) + - **Env vars read:** + - JSON-parsed: `ONLY_BUMP_VERSION`, `ONLY_DELETE_ARTIFACTS`, `SNAPSHOT`. + - Plain: `TAG`, `PR_NUMBER`, `BASE` (`origin/` prefixed; all-zero SHA → `origin/`, lines 54-62), `DEFAULT_BRANCH`. + - Runner context: `GITHUB_EVENT_NAME`, `GITHUB_HEAD_REF`, `GITHUB_REF_NAME` (`utils.ts:269-284`). + - **TAG mode** (65-70): `TAG` starts with `version/`. Sets `onlyAffected=false` and `task=RELEASE`; the version is parsed from the tag. + - **Release-branch mode** (72-78): the branch starts with `release/`. Sets `currentVersion = release/X.Y → X.Y.0` and `task=RELEASE`; `onlyAffected` stays true. + - **Fallback:** snapshot or PR modes. +- **`handle()`** (100-127) + - Branch-off guard: the tip commit subject starts with `[branch-off]` → return. It skips tag and publish (`utils.ts:247-255`; added f74ee0a / #180, PFM-ISSUE-34805). + - `ONLY_BUMP_VERSION && branch startsWith 'release/'` → `bumpVersionForReleaseBranch()`. + - Otherwise `initProjects()` + `buildAndPublishProjects()`. + - Gotcha: `ONLY_BUMP_VERSION=true` on a non-release branch silently falls through to publishing. +- **Version derivation** + - `Utils.getAllVersionTagsInAscendingOrder` (`utils.ts:210-230`) runs `git ls-remote --tags`, keeps `version/` tags and sorts them by major/minor/patch. It reads the remote, not local tags. + - `getLatestTagForReleaseBranch` (`utils.ts:175-193`) returns the highest tag with the same major.minor. + - `calculateNewVersion` (`utils.ts:195-208`) returns `X.Y.1` if there is no tag, otherwise latest patch + 1. + - Pre-release suffixes are dropped on parse. + - `VERSION_BUMP.MINOR` exists (`version.ts:23-25`) but is never used. No release-type concept exists. +- **Tag creation** (`artifacts-handler.ts:187-207`) + - It is created only if `affectedProjects.length > 0 || patch === 1`. Otherwise it logs "no new Minor/Patch version is needed" and creates no tag. + - The commands are `git tag version/X.Y.Z` (lightweight, HEAD) and `git push origin ` via `execSync`. A failure throws. + - There is no existing-tag check, no retry and no lock. + - Re-running after a successful push computes the *next* patch, so it is not idempotent. + - Two concurrent runs can compute the same tag; the loser's push fails. +- **Publish** (`buildAndPublishProjects`, 129-154; `nx-project.ts`) + - RELEASE publishes *all* projects (`Utils.getAllNxProjects`, `utils.ts:87-120`). Libs are published only if `publishable: true` (`nx-project.ts:49-57`); apps always (58-60). + - Steps per project: + 1. `nx build --prod`. + 2. Write `dist/.../.npmrc` (`_auth` etc.; the token content is logged). + 3. Set the version in the dist `package.json` with `publishConfig.tag = release-X.Y`. + 4. `npm publish`. + - There is no pre-check for an existing version on RELEASE. `deleteArtifact` (unpublish) is used only for PR snapshots (148-149). + - `publish()` (108-126) calls `process.exit(1)` on effectively any error. + - **Consequence:** a partial publish can't be resumed. A re-run hits a 403 on the first already-published package. +- **Outputs:** the computed version and the "tag created?" flag are only `console.log`ged. Nothing is written to `$GITHUB_OUTPUT`. +- **Tests (jest):** + - `artifacts-handler-tag.test.ts` covers the bump on `release/5.18` → `version/5.18.121`, the first push → `.1`, and TAG mode → `onlyAffected=false`. + - `artifacts-handler-branch-off.test.ts` covers the branch-off guard. + - `utils.test.ts` covers tag ordering and the bump calculation. + - `nx-project.test.ts` covers publishability and `deleteArtifact`. + - Gaps: the exact `git tag`/`git push` commands, `publish()`, `build()`, and existing-tag or retry cases are not asserted. + +### 4. Caller side across all FE repos (23 callers) +- **Universal pattern, default branch:** + - A release-branch workflow (`push: branches: ['release/*']`) runs install-deps → build ∥ e2e-tests → `tag` (fe-tag). + - A tag workflow (`push: tags: ['*/*']`) runs `release-version` (fe-release). + - No `workflow_dispatch`, `workflow_run` or `concurrency` on any release/tag workflow. No caller creates GitHub releases. +- **Callers:** + - 20 `*-fe` repos. + - `cplace-legacy-fe-assets`. + - `cplace-frontend-applications`, `cplace-frontend-consumer`, `cplace-frontend-documentation-test`. + - `cplace-board-fe` has no workflows. +- **File-name variants:** + - Template names `fe-release.yml` / `fe-tag-pushed.yml` (most repos). + - `push_to_release.yaml` / `on_tag_pushed.yaml` (cplace-fe, legacy-fe-assets, frontend-applications). + - `push_release.yml` / `tag_pushed.yml` (office-reports-fe, workflow-fe). + - `release.yml` / `tag_pushed.yml` (loomeo-fe). +- **`squad-fe` guard:** `if: github.event.pusher.name != 'squad-fe'` on the tag job in cplace-fe (`push_to_release.yaml:43`), cplace-frontend-applications (`:36`) and cplace-legacy-fe-assets (`:29`). It prevents bot pushes (upmerge/version commits) from releasing. +- **No e2e before tag:** cplace-legacy-fe-assets, cplace-loomeo-fe, cplace-dataciders-portfolio-management-fe (`needs: build` only). +- **Broken or non-standard callers:** + - **cplace-bayer-prompt-fe** calls a non-existent `fe-release.template.yml@release/25.4`. It uses `v1.80.x` / `release/sprint/NN` tags and person-named release branches. + - **cplace-frontend-consumer** has a fe-tag job named `snapshot` that `needs` non-existent jobs. +- **Permissions:** + - Most repos: `write-all`. + - Granular: cplace-fe, cplace-paw-fe. + - None: frontend-applications, frontend-consumer, frontend-documentation-test (dormant, last tags 23.1). +- **cplace-fe** excludes `!release/*-beta` in both filters. On the tag filter this has no effect. +- **Version lines:** + - Typical: `release/YY.N` → `version/YY.N.P`. + - Outliers: + - cplace-qgate-fe: `release/3.20`, `release/3.20.1`, `release/25.1-3.18`. + - cplace-dortmund-mps-fe: three-part branches like `release/26.10.2`, mixed zero padding. + - cplace-pag-rm-fe: `release/25.2.1`. +- **Stray `*/*` tags that trigger publishes today:** `demo/26.1-enabled-export` (dataciders), `release/sprint/NN` (bayer), and UI-created releases `version/25.4`, `version/26.1` (office-reports, workflow-fe), which would run `fe-release` with a two-part version. +- **Hand-made GitHub releases** (not immutable): + - bayer-prompt (43), dortmund-mps (15), office-reports (6), workflow-fe (8), pag-rm (6). + - dataciders: 1 prerelease. project-planning: 1 draft. + - Others: 0. + +### 5. Ref pinning and rollout mechanics +- On default branches, callers and github-actions internals reference `@master`. On every release branch, both github-actions and consumer repos reference `@release/YY.N`. Verified examples: + - `github-actions` `origin/release/26.3` and `origin/release/26.4` use `artifacts@release/26.x` and `fe-tag.yml@release/26.x`. + - `cplace-fe` `origin/release/26.3` `push_to_release.yaml` / `on_tag_pushed.yaml` use `fe-tag.yml@release/26.3` and `fe-release.yml@release/26.3`. +- cf-devkit branch-off creates `release/X.Y`, rewrites workflow refs `@master` → `@release/X.Y`, publishes `X.Y.0` locally, then reverts refs on the default branch. The commit is marked `[branch-off]` (cplace-fe `specs/2026-06-15_branch-off-two-run-split/research.md:50-59,86,92`). +- github-actions release branches are upmerged in a chain (`release/25.2 → … → 26.4 → master`). Each change is applied per branch with per-branch composite refs (`specs/2026-06-05_node24-workflow-migration/design.md:21,53`). +- Some repos pin non-matching refs: 2lforge-scores-fe `release/26.1` (plus `fe-main` on a `fix/…` branch), toolbar-fe `release/26.3`, bayer, dortmund and pag-rm `release/25.4`, qgate `release/25.2`, loomeo `release/26.2`, resource-management `release/26.2`. +- A tag-push workflow runs the YAML at the tagged commit. A push to `release/*` runs that branch's YAML. So the consolidation takes effect per release branch. An unmigrated branch keeps working with the old two-run flow. On a migrated branch, the old tag-push workflow must be gone from the same tree, or the new workflow's PAT tag push triggers it. + +### 6. Runtime evidence (cplace-fe Actions) +- **Release cycle:** + - Branch push run 37911177252 (2026-10-09 09:25), then tag run 37917734582 at 10:27 for `version/26.1.67` (publish 10:28-10:54). The cycle takes about 1 h, mostly e2e. + - All tag runs have actor `squad-fe`. +- **Stage-1 failures:** mostly flaky e2e, which blocks the tag. For example 37334564468 (`release/25.4`) needed 6 attempts. Others: 37008207766, 37001985003, 36987129383, 36883349635. +- **Stage-2 failures**, all in "Build and Push to Jfrog NPM Registry": + - 35917324069 `version/26.4.1` and 28507554262 `version/26.3.1`: "You cannot publish over the previously published versions". These collided with the branch-off local publish on branch-off day. + - 30539012683 `version/26.1.55` (re-run): E403 on `@cplace-next/cf-frontend-customization`. + - 29495780323 `version/26.1.50`, and 28936356996 `version/26.3.2` (3 attempts). +- **2026-06-08 incident** (`specs/2026-06-05_node24-workflow-migration/validation-log.md:33-41`): test commits pushed to `release/25.2` ran the release pipeline. The "no new Minor/Patch version is needed" guard prevented a tag. Rule adopted: never test on `release/*`; use `test/*` branches and `cplace-remote-filesystem-fe`. +- **GitHub state for cplace-fe:** + - Releases: none. + - Immutable releases: `{"enabled":false,"enforced_by_owner":false}`. + - Rulesets: four, all targeting branches (`prevent-release-deletion`, `prevent-release-version-creation`, `product-fe`, `experimental bypass sva-actions-upmerge`). No tag ruleset. + +## Code References +- `.github/workflows/fe-tag.yml:12-24` – required secrets incl. `GIT_USER_TOKEN`, `GIT_USER`, `GIT_USER_EMAIL` +- `.github/workflows/fe-tag.yml:30-34` – checkout with the PAT (the tag push credential) +- `.github/workflows/fe-tag.yml:45-48` – git identity from secrets +- `.github/workflows/fe-tag.yml:50-58` – artifacts action, `ONLY_BUMP_VERSION: true` +- `.github/workflows/fe-release.yml:15-17` – `NX_BRANCH` / `NX_RUN_GROUP` env +- `.github/workflows/fe-release.yml:23-26` – checkout `ref: ''` (the tag) +- `.github/workflows/fe-release.yml:48-54` – artifacts action, `TAG: ${{ github.ref_name }}` +- `.github/workflows/fe-install-deps.yml:16-18` – cancelling concurrency keyed on `github.ref` +- `.github/workflows/fe-code-quality.yml:92-112` – only existing job-output + `needs` pattern +- `.github/workflow-templates/fe/fe-release.yml:18-53` – caller-side build → e2e → tag sequence +- `.github/workflow-templates/fe/fe-tag-pushed.yml:3-15` – `tags: ['*/*']` trigger, calls fe-release +- `.github/actions/artifacts/action.yml:6-10` – printenv / npm ci / ts-node main +- `tools/scripts/artifacts/artifacts-handler.ts:21` – `VERSION_PREFIX = 'version/'` +- `tools/scripts/artifacts/artifacts-handler.ts:38-98` – env-driven mode selection +- `tools/scripts/artifacts/artifacts-handler.ts:100-127` – `handle()`: branch-off guard, bump vs publish +- `tools/scripts/artifacts/artifacts-handler.ts:129-154` – build and publish loop +- `tools/scripts/artifacts/artifacts-handler.ts:187-207` – `bumpVersionForReleaseBranch()`: guard, `git tag`, `git push` +- `tools/scripts/artifacts/utils.ts:175-230` – latest tag per release line, new-version calculation, `ls-remote` tag listing +- `tools/scripts/artifacts/utils.ts:247-255` – `isBranchOffCommit()` +- `tools/scripts/artifacts/version.ts:10-56` – Version parsing, `getGitTag()`, `getNpmTag()` (`release-X.Y`) +- `tools/scripts/artifacts/nx-project.ts:49-60,108-139,183-215` – publishability, `publish()` exit-on-error, build flags, `packageExists` +- `tools/scripts/artifacts/artifacts-handler-tag.test.ts:25-101` – tag/bump tests +- `cplace-fe/.github/workflows/push_to_release.yaml:6,43-44` – branch filter incl. `!release/*-beta`, `squad-fe` guard, fe-tag call +- `cplace-fe/.github/workflows/on_tag_pushed.yaml:6,20` – tag trigger, fe-release call + +## Architecture Insights +- **What the PAT is actually for.** The two-workflow split exists because a tag pushed with `GITHUB_TOKEN` doesn't trigger workflows. A single workflow removes that coupling, which is why 34283 must land before the `GITHUB_TOKEN` switch (34282). Under the current PAT, the new workflow's own tag push still triggers `on: push: tags` workflows present in the tagged commit's tree. Migrating a branch therefore means replacing the old pair of caller workflows with the new one in a single change. That replacement is part of the migration, not a separate clean-up, and unmigrated branches are left alone. +- **Disabling manual tag releases is a repository setting, not only a workflow change.** A tag ruleset on `version/*` that restricts creation, with a bypass for the release actor (`squad-fe` today, the Actions token after PFM-ISSUE-34282), enforces "manual tag creation no longer triggers a release" regardless of which workflow files exist on a commit. It would also let an intentional tag-triggered recovery path remain, if one is wanted. Pushing the tag with `GITHUB_TOKEN` would avoid the trigger entirely, but that overlaps with PFM-ISSUE-34282 and the ticket's "current auth" constraint. +- **Version derivation is stateless and remote-based** (`ls-remote`), which matches the ticket's retry semantics: + - A failed tag push → the next run recomputes the same version. + - A pushed tag whose publish failed → the next run uses the next patch, leaving a dangling tag. + - The consolidated workflow must therefore carry the *computed* version from the tag job to the publish and release jobs as an output. It can't be recomputed, and can't use `github.ref_name`. + - GitHub's "Re-run failed jobs" keeps outputs of successful jobs, so re-running only the publish job keeps the same version. But today's publish code can't resume a partial publish. +- **The artifacts tool is a mode-switching monolith driven by env vars.** Adding a "tag and emit version" mode or a `$GITHUB_OUTPUT` write is the natural extension point. TAG mode already supports publishing a given `version/X.Y.Z` independent of `github.ref`. +- **The "no new version needed" guard is a deliberate safety net.** It held during the 2026-06-08 incident. Downstream jobs in the single workflow must be conditional on "tag created". +- **Two guards skip releasing:** the `[branch-off]` commit guard (tool-side) and the `squad-fe` pusher guard (caller-side, 3 repos). Both need equivalents. +- **Composition patterns available:** caller-level `needs:` chains (templates), the aggregate `pr-checks` job pattern (`fe-pr.yml:25-50`), and job outputs (`fe-code-quality`). Nothing yet exposes `on.workflow_call.outputs`. +- **Every change is replicated per release branch** (github-actions upmerge chain plus branch-off ref rewriting), and callers vary by file name. A new opt-in reusable workflow, rather than changing `fe-tag.yml`/`fe-release.yml` in place, would avoid breaking callers that still use the old contract during the rollout. That is an inference to evaluate in design. +- **Immutable releases** lock a release's tag and assets once published. The ticket's "finalize after publish" maps naturally to draft → (publish succeeded) → publish release. Enabling immutability per repo is PFM-ISSUE-34284. Existing hand-made releases in five repos and stray `*/*` tags must be considered. + +## Historical Context (from specs/) +- `github-actions/specs/2026-06-05_node24-workflow-migration/` (research, design, plan, sha-pins, validation-log): + - Covers the rollout across the release-branch upmerge chain and the per-branch composite refs. + - Replaced `dawidd6/action-get-tag` with `github.ref_name` in fe-release. + - Sets the SHA-pin baseline: checkout v6.0.3, setup-node v6.4.0, cache v5.0.5, upload-artifact v7.0.1, download-artifact v8.0.1, github-script v9.0.0. + - The validation approach used `cplace-remote-filesystem-fe` plus a test branch. **fe-release was never validated live** (`validation-log.md:71`). +- `cplace-fe/specs/2026-06-06_branch-off-publish-artifacts-dummy-change/`: the `publishArtifacts` touch forces libs into `nx affected` so release-branch pushes republish. It explains why branch-off pushes would trigger tag and publish. +- `cplace-fe/specs/2026-06-15_branch-off-two-run-split/`: branch-off phases, the local release publish of `X.Y.0` in MasterSync, and `@master` ↔ `@release/X.Y` rewriting. This is the source of the branch-off-day `.1` collisions. +- `cplace-fe/specs/2026-06-11_branch-off-publishartifacts-apps-support/`, `2026-06-11_branch-off-publishartifacts-package-json/`, `2026-06-12_branch-off-local-publish/`: related branch-off publish mechanics (not analysed in depth). +- `cplace-fe/specs/2026-09-09_PFM-TASK-8335_node-24-upgrade/`: lists the reusable workflows consumed by cplace-fe. +- `cplace-project-planning-fe/specs/2026-07-24_cf-gantt-core-publishable-necessity/research.md:63,102`: tag-triggered release pipeline per repo, and "publish all publishable" semantics. +- `cplace-project-planning-fe/specs/2026-02-26_application-test-infrastructure/research.md:450`: lists the `GIT_USER*` secrets. + +No spec yet covers PFM-ISSUE-33676, PFM-ISSUE-34283, the GITHUB_TOKEN switch, or immutable releases. + +## Related Research +- `specs/2026-06-05_node24-workflow-migration/research.md` (this repo) +- `cplace-fe/specs/2026-06-15_branch-off-two-run-split/research.md` +- `cplace-fe/specs/2026-06-06_branch-off-publish-artifacts-dummy-change/research.md` + +## Open Questions +Status as of 2026-10-13. Decisions are detailed in "Follow-up 2026-10-13 — Decisions on open questions". + +1. **Trigger semantics.** *Decided:* the push trigger stays `push: branches: ['release/*']`. `workflow_dispatch` gets a **force** input (see the 2026-10-12 follow-up). +2. **Shape of the solution.** *Decided:* one reusable workflow contains all jobs (variant A1). Each caller calls just that one reusable workflow. +3. **Version hand-off.** *Decided:* the version is written as a step/job output. +4. **Publish re-run safety.** *Decided:* no skip-if-exists. A re-run calculates and publishes the next version. A broken version stays broken, and its artifacts stay partly published. +5. **Dangling tags.** *Decided:* nothing is done; the tag remains. +6. **GitHub release finalization.** *Decided:* the release is created only after a successful publish, with GitHub's auto-generated title and notes, using `GITHUB_TOKEN`. No special handling for repos with existing hand-made releases. +7. **Concurrency.** *Decided:* use a non-cancelling concurrency group per branch for the whole release run. +8. **Guards.** *Decided:* keep the "no new version needed", `[branch-off]` and `squad-fe` guards for now. +9. **Branch-off day collision.** *Deferred.* +10. **Manual tag protection** (tag ruleset, recovery path). *Deferred.* +11. **Rollout scope.** *Decided:* all FE repos on `release/25.4` and later (25.4, 26.1, 26.2, 26.3, 26.4) plus the default branch. Older release branches are not supported. Repos without e2e are handled by a workflow input (see Q2 in the 2026-10-13 follow-up). +12. **Live validation.** *Decided:* a live test setup will be prepared. +13. **Epic PRD.** *Deferred.* +14. **Force input details.** *Deferred.* + +## Follow-up Research 2026-10-12T17:13 — Force input for manual releases + +### Proposed direction (from discussion) +The `workflow_dispatch` input from the ticket becomes a **force** flag. It is allowed only on the default branch and on `release/*` branches. +- **On `release/*`:** force skips the "no new Minor/Patch version is needed" check. The run then tags, publishes and finalizes the release even when no project is affected. +- **On the default branch (`master`/`main`):** force publishes a snapshot as usual. No git tag or GitHub release is created. + +This gives people a supported alternative to creating tags by hand, which complements a `version/*` tag ruleset. + +### Release branches +- The only thing blocking a tag today is the condition `projects.length > 0 || calculatedNewVersion.patch === 1` (`tools/scripts/artifacts/artifacts-handler.ts:193`). A force flag only needs to bypass it. +- Version derivation is unchanged: latest remote `version/X.Y.*` + 1 patch. The ticket's retry semantics therefore still hold. +- The publish step already publishes all publishable projects in TAG mode (`onlyAffected = false`, `artifacts-handler.ts:65-70`). A forced release with nothing affected still produces a complete set of packages. +- `[branch-off]` guard (`artifacts-handler.ts:109-114`): force should probably not override it, because it prevents re-releasing what branch-off already published. + +### Default branch: snapshot behaviour today +- **Version.** Main snapshots are versioned `0.0.0-SNAPSHOT--` (`Utils.getUniqueSnapshotIdentifier` / `getHashedTimestamp`, `tools/scripts/artifacts/utils.ts:296-315`; set in `artifacts-handler.ts:80-86`). Every run gets a new, unique version, so no existing version is ever overwritten. +- **npm dist-tag.** Main snapshots are published under the dist-tag `snapshot` (`nx-project.ts:356`). A newer snapshot "overrides" the previous one by moving that dist-tag. Consumers installing `@scope/pkg@snapshot` get the latest. +- **Delete-then-publish is PR-only.** `deleteArtifact` is called before publishing only for `TASK.PR_SNAPSHOT` (`artifacts-handler.ts:148-149`), whose version is deterministic (`0.0.0--`, `utils.ts:300-304`). The JFrog existence check (`packageExists`, `nx-project.ts:183-215`, #178 / 5bcad87) runs as part of that. +- **Cleanup.** Old main snapshots are removed separately by `fe-cleanup-snapshots.yml` via `NxProject.deleteSnapshots` (`nx-project.ts:161-171`). +- **Republishing the same snapshot version is not recommended.** Unpublishing and republishing a version with different content breaks consumers whose lockfiles pinned the old integrity hash (`EINTEGRITY`). The default-branch `snapshot` is consumed widely, so a forced run should keep the existing "new version per run" behaviour. + +### Gap: affected calculation on a manual run +- **Main snapshots are affected-only.** `onlyAffected` defaults to `true` (`artifacts-handler.ts:28`). Only TAG mode sets it to `false`. +- **The base comes from the push event.** The affected base is `BASE = github.event.before` (callers pass `GHA_BASE: ${{ github.event.before }}`). On `workflow_dispatch` there is no `before`, so `BASE` arrives empty. +- **An empty base is invalid.** The constructor turns it into `origin/` (`artifacts-handler.ts:52-56`), which is invalid. The all-zero-SHA fallback to `origin/` (57-61) does not catch an empty string. +- **What a forced run needs.** A forced default-branch run must publish all projects (as TAG mode does) or be given an explicit base. Otherwise it either errors or publishes nothing. +- **The same gap affects forced release-branch runs** if the tag step still computes affected projects. With force bypassing the check, the affected list for tagging is irrelevant, but `nx show projects --affected --base=origin/` would still run unless skipped. + +### Enforcing the branch restriction +- `workflow_dispatch` lets the user pick any branch, so the restriction must be enforced in the workflow. For example, use a job-level `if:` that allows `github.ref_name == github.event.repository.default_branch` or `startsWith(github.ref, 'refs/heads/release/')`. +- Compare against `github.event.repository.default_branch` rather than a hard-coded `master`, because `cplace-fe`'s default branch is `main`. +- Forced runs should go through build + test like push runs (the ticket's ordering). They should also share the same non-cancelling concurrency group, so a forced run and a push run on the same branch can't compute the same tag. + +## Follow-up 2026-10-13 — Decisions on open questions + +### Decided +- **Trigger (Q1).** `push: branches: ['release/*']` stays as is. `workflow_dispatch` adds the force input described in the 2026-10-12 follow-up. +- **Shape (Q2).** A new single workflow runs every job: install-deps, build, e2e, tag, publish, release. The existing `fe-tag.yml` / `fe-release.yml` stay untouched for unmigrated callers. *Decided 2026-10-13 (second round):* all jobs live in one reusable workflow, and each caller calls only that workflow (variant A1 below). +- **Version hand-off (Q3).** + - The tag step writes the computed version as an output. Downstream jobs read it via `needs..outputs.*` and pass it to publish as `TAG=version/X.Y.Z`; TAG mode already supports this (`artifacts-handler.ts:65-70`). + - The output must also tell whether a tag was created, so publish and release are skipped when "no new version is needed". + - The artifacts tool does not write to `$GITHUB_OUTPUT` today; this needs to be added. +- **Dangling tags (Q5).** No action. A tag whose publish failed remains, and the next run moves on to the next patch. +- **GitHub release (Q6).** + - The release is created only after a successful publish, for the tag created in the same run. + - It uses GitHub's auto-generated title and notes, e.g. `gh release create --generate-notes --verify-tag`, or the REST equivalent with `generate_release_notes: true`. + - It is created with `GITHUB_TOKEN`, not the PAT. The job needs `contents: write`. Most callers already grant it via `write-all` or granular permissions. Repos without a `permissions:` block fall back to the org/repo default token permissions. + - Under the current auth the tag itself is still pushed with the PAT (ticket constraint). The release step is the first part that already uses `GITHUB_TOKEN`. + - Repos with existing hand-made releases get no special handling; notes are generated the same way and can be edited later by hand. +- **Concurrency (Q7).** + - The whole release run gets a non-cancelling concurrency group per branch, e.g. `group: fe-release-${{ github.ref }}` with `cancel-in-progress: false`. + - Inside the run, the job-level group in `fe-install-deps.yml:16-18` (`fe-install-deps-${{github.ref}}`, `cancel-in-progress: true`) must not cancel a release run. Either the new workflow doesn't reuse it as is, or the group is made distinct. +- **Guards (Q8).** Keep the "no new Minor/Patch version is needed" check, the `[branch-off]` commit guard, and the `squad-fe` pusher guard for now. +- **Rollout (Q11).** + - Target: all FE repos on `release/25.4` and newer release branches, plus the default branch. Older release branches are not supported and keep the old two-run flow. + - Each migrated branch replaces the old branch-push and tag-push caller pair with the new workflow in one change. + - github-actions itself needs the new workflow on `master` and on `release/25.4` … `release/26.4` with per-branch refs, following the upmerge chain. + - Repos without e2e on the release path are handled according to the Q2 outcome. +- **Live validation (Q12).** A dedicated live test setup will be prepared. + +### Deferred +Q9 (branch-off day collision), Q10 (tag ruleset / recovery path), Q13 (epic PRD), Q14 (force input details). + +### Q2 — where the jobs live (decided: variant A1) +**Variant A1: one reusable workflow containing all jobs** (e.g. `fe-release-flow.yml`). Inside it, jobs call the existing reusable workflows (`fe-install-deps`, `fe-build`, `fe-e2e`) plus new tag, publish and release jobs. The caller has a single `uses:`. +- The job order, guards, concurrency, version outputs and release step are written once in github-actions and rolled out per release branch there. Callers stay small and identical. +- Today's callers show drift: five file-name variants, two broken callers, and the `squad-fe` guard present in only three repos. +- Version outputs pass between jobs inside the same workflow via `needs`, so no `on.workflow_call.outputs` is required. +- The caller must pass all secrets: JFrog ×3, `DOT_NPMRC`, `GIT_USER*`, and optional `PERCY_TOKEN`; `secrets: inherit` is the simpler alternative. It must also grant `contents: write`, since nested workflows can only narrow permissions. +- Nesting reusable workflows inside a reusable workflow is subject to GitHub's nesting-depth limits. One extra level (caller → release flow → existing reusables) should be within them; check the current documented limit. +- E2e control has to be expressed as an input. See "E2e handling" below. + +**Variant A2: the caller lists the individual jobs.** Each caller defines install-deps → build ∥ e2e → tag → publish → release, referencing individual reusable workflows. This needs new reusable workflows for tag (with `workflow_call` outputs), publish (with a version input) and release. +- Per-repo flexibility is natural: a repo without e2e simply omits the job. +- The ordering, `needs`/`if` wiring, version plumbing, guards and concurrency are repeated in every caller. That means about 20 repos × 6 branches (master + 25.4 … 26.4), and every later change, such as the `GITHUB_TOKEN` switch in PFM-ISSUE-34282, touches all of them again. +- It requires `workflow_call` outputs on the tag workflow, which has no precedent in this repo. + +**E2e handling.** +- `fe-e2e.yml` already tolerates repos with no affected e2e projects. `run-many.ts:86-90` logs "No affected projects :)" and exits successfully, but the Cypress container shards still start. +- In A1, the choice is between an explicit boolean input such as `RUN_E2E`, default `true` (callers in cplace-legacy-fe-assets, cplace-loomeo-fe and cplace-dataciders-portfolio-management-fe set `false`), or relying on that existing no-op behaviour. +- The input is explicit and also avoids e2e for repos that do have e2e projects but deliberately skip them on release. Why those three repos skip e2e is not known. + +### Explanation: Q4 publish re-run safety +- **The situation.** The single workflow's publish job fails part-way: packages A and B are published as `X.Y.Z`, package C fails, e.g. with a transient E403 or a network error. +- **"Re-run failed jobs".** GitHub keeps the outputs of the successful tag job, so the re-run publishes the same version `X.Y.Z`. +- **Today's code** publishes every package unconditionally. On re-run, A fails with "cannot publish over the previously published version" and `publish()` exits (`nx-project.ts:108-126`), so C can never be completed in that run. +- **Without a change**, the only way forward is a new run, e.g. a new push or a forced dispatch. It computes `X.Y.Z+1` and publishes everything again. This matches the ticket's semantics ("a failed publish advances the next run to the next version"), leaving `X.Y.Z` as a dangling, partially published version. +- **With skip-if-exists**, before each `npm publish` the tool checks whether `name@X.Y.Z` already exists in JFrog. `NxProject.packageExists` (`nx-project.ts:183-215`) does exactly this and is already used for PR snapshots. If it exists, the package is skipped. The re-run then publishes only C and the release step can follow, completing `X.Y.Z` instead of abandoning it. +- **Trade-offs:** + - Skipping assumes the existing package came from the same commit's build, which holds for a re-run of the same run. + - It would also silently skip a genuine collision, e.g. a version published locally by branch-off. Today that case fails loudly, as in `version/26.4.1`. + - Restricting skip-if-exists to re-runs (`github.run_attempt > 1`) keeps the loud failure on first attempts. +- **Decision (2026-10-13):** no skip-if-exists. A re-run must calculate and publish the **next** version. The broken version stays broken, and its artifacts stay partly published. +- **Consequence for the job layout:** + - GitHub's "Re-run failed jobs" keeps the outputs of jobs that succeeded. If tag and publish were separate jobs, re-running a failed publish job would reuse the old tag job's output and try `X.Y.Z` again, which conflicts with the decision. + - Version calculation, tag push and publish should therefore run **in the same job** (tag step, then publish step). Re-running that job recalculates from the remote tags: `X.Y.Z` now exists, so it computes `X.Y.Z+1`, pushes the new tag and publishes. + - The GitHub release job then depends on that job and reads the version from its output. A re-run of only the release job, after a successful publish, reuses the correct version. + - "Re-run all jobs" also gives the next version, because the tag is recalculated, but it repeats build and e2e. + - The "no new version needed" check must not suppress the re-run. On the re-run, the affected set relative to `BASE` (`github.event.before`) is the same as in the first attempt, so tagging is still triggered. From 45af126aeb7478d4c73d16129578e399e77d9c2f Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Tue, 20 Oct 2026 16:19:29 +0200 Subject: [PATCH 02/11] PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow - 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) --- .github/actions/artifacts/action.yml | 16 +- .github/workflow-templates/fe/fe-release.yml | 58 ++-- .../workflow-templates/fe/fe-tag-pushed.yml | 24 -- .github/workflows/fe-install-deps.yml | 3 - .github/workflows/fe-release-flow.yml | 165 +++++++++++ README.md | 70 +++++ .../plan.md | 213 ++++++-------- .../artifacts-handler-release-flow.test.ts | 272 ++++++++++++++++++ tools/scripts/artifacts/artifacts-handler.ts | 96 ++++++- tools/scripts/artifacts/utils.test.ts | 13 + tools/scripts/artifacts/utils.ts | 25 +- .../run-many/affected-projects.test.ts | 64 +++++ tools/scripts/run-many/affected-projects.ts | 4 + tools/scripts/run-many/run-many.ts | 4 +- 14 files changed, 829 insertions(+), 198 deletions(-) delete mode 100644 .github/workflow-templates/fe/fe-tag-pushed.yml create mode 100644 .github/workflows/fe-release-flow.yml create mode 100644 tools/scripts/artifacts/artifacts-handler-release-flow.test.ts create mode 100644 tools/scripts/run-many/affected-projects.test.ts diff --git a/.github/actions/artifacts/action.yml b/.github/actions/artifacts/action.yml index c0fa97cb..88790108 100644 --- a/.github/actions/artifacts/action.yml +++ b/.github/actions/artifacts/action.yml @@ -1,5 +1,18 @@ name: 'Frontend Artifacts Handler Action' description: 'Github Action to handle frontend artifacts' +outputs: + released: + description: 'true if a version was tagged and published (release flow only)' + value: ${{ steps.handler.outputs.released }} + tag: + description: 'The pushed version tag (release flow only)' + value: ${{ steps.handler.outputs.tag }} + previous_tag: + description: 'The previous tag of the same release line, empty for X.Y.1 (release flow only)' + value: ${{ steps.handler.outputs.previous_tag }} + is_latest: + description: 'true if the pushed tag is the highest version/* tag (release flow only)' + value: ${{ steps.handler.outputs.is_latest }} runs: using: 'composite' steps: @@ -7,5 +20,6 @@ runs: shell: bash - run: cd "$GITHUB_ACTION_PATH/../../.." && pwd && npm ci shell: bash - - run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/artifacts/main.ts" + - id: handler + run: npx ts-node "$GITHUB_ACTION_PATH/../../../tools/scripts/artifacts/main.ts" shell: bash diff --git a/.github/workflow-templates/fe/fe-release.yml b/.github/workflow-templates/fe/fe-release.yml index 80902e34..d0f131b6 100644 --- a/.github/workflow-templates/fe/fe-release.yml +++ b/.github/workflow-templates/fe/fe-release.yml @@ -1,53 +1,29 @@ +# Replaces the old two-file release (this file as tagger + fe-tag-pushed.yml / on_tag_pushed.yaml / tag_pushed.yml). +# Delete BOTH old caller files in the same commit that adds this one: the release flow still pushes +# the tag with the PAT, so a tag-push workflow left in the tree would publish a second time. name: 'Frontend Release CI' on: push: branches: - 'release/*' + workflow_dispatch: + inputs: + force: + description: 'Tag and publish (release/*) or publish a snapshot of all projects (default branch). Unticked: build and test only, nothing is tagged or published.' + type: boolean + default: false permissions: contents: write - pull-requests: write - actions: read - checks: write -env: - NX_BRANCH: ${{ github.event.number }} - NX_RUN_GROUP: ${{ github.run_id }} +concurrency: + group: fe-release-${{ github.ref }} + cancel-in-progress: false jobs: - install-deps: - uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@master + release-flow: + uses: collaborationFactory/github-actions/.github/workflows/fe-release-flow.yml@master with: - GHA_REF: '' - secrets: - DOT_NPMRC: ${{ secrets.DOT_NPMRC }} - - e2e-tests: - needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@master - with: - GHA_REF: '' - GHA_BASE: ${{ github.event.before }} - secrets: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} - - build: - needs: install-deps - uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@master - with: - GHA_REF: '' - GHA_BASE: ${{ github.event.before }} - - tag: - needs: [build, e2e-tests] - uses: collaborationFactory/github-actions/.github/workflows/fe-tag.yml@master - with: - GHA_REF: '' - GHA_BASE: ${{ github.event.before }} - secrets: - JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} - JFROG_URL: ${{ secrets.JFROG_URL }} - JFROG_USER: ${{ secrets.JFROG_USER }} - GIT_USER_TOKEN: ${{ secrets.GIT_USER_TOKEN }} - GIT_USER: ${{ secrets.GIT_USER }} - GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }} + FORCE: ${{ inputs.force == true }} + # RUN_E2E: false # only for repos that do not run e2e before a release + secrets: inherit diff --git a/.github/workflow-templates/fe/fe-tag-pushed.yml b/.github/workflow-templates/fe/fe-tag-pushed.yml deleted file mode 100644 index 6348c47b..00000000 --- a/.github/workflow-templates/fe/fe-tag-pushed.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: 'Frontend Tag Pushed CI' -on: - push: - tags: - - '*/*' - -permissions: - contents: write - pull-requests: write - actions: read - checks: write - -env: - NX_BRANCH: ${{ github.event.number }} - NX_RUN_GROUP: ${{ github.run_id }} - -jobs: - release-version: - uses: collaborationFactory/github-actions/.github/workflows/fe-release.yml@master - secrets: - JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} - JFROG_URL: ${{ secrets.JFROG_URL }} - JFROG_USER: ${{ secrets.JFROG_USER }} - DOT_NPMRC: ${{ secrets.DOT_NPMRC }} diff --git a/.github/workflows/fe-install-deps.yml b/.github/workflows/fe-install-deps.yml index f909d005..31455b08 100644 --- a/.github/workflows/fe-install-deps.yml +++ b/.github/workflows/fe-install-deps.yml @@ -13,9 +13,6 @@ on: jobs: install-deps: runs-on: ubuntu-latest - concurrency: - group: fe-install-deps-${{ github.ref }} - cancel-in-progress: true steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/fe-release-flow.yml b/.github/workflows/fe-release-flow.yml new file mode 100644 index 00000000..230b5071 --- /dev/null +++ b/.github/workflows/fe-release-flow.yml @@ -0,0 +1,165 @@ +name: Frontend Release Flow Workflow + +on: + workflow_call: + inputs: + FORCE: + description: 'release/*: release even if no project is affected. Default branch: publish a snapshot of all projects. false: build and test only.' + type: boolean + required: false + default: false + RUN_E2E: + type: boolean + required: false + default: true + secrets: + JFROG_BASE64_TOKEN: + required: true + JFROG_URL: + required: true + JFROG_USER: + required: true + DOT_NPMRC: + required: true + GIT_USER_TOKEN: + required: true + GIT_USER: + required: true + GIT_USER_EMAIL: + required: true + PERCY_TOKEN: + required: false + +env: + NX_BRANCH: ${{ github.event.number }} + NX_RUN_GROUP: ${{ github.run_id }} + +jobs: + guard: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Check dispatch ref + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + DEFAULT_REF: refs/heads/${{ github.event.repository.default_branch }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$REF" != "$DEFAULT_REF" ] && [[ "$REF" != refs/heads/release/* ]]; then + echo "::error::The release flow can only be dispatched on the default branch or a release/* branch, not on $REF" + exit 1 + fi + + install-deps: + needs: guard + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-install-deps.yml@master + with: + GHA_REF: '' + secrets: + DOT_NPMRC: ${{ secrets.DOT_NPMRC }} + + build: + needs: install-deps + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-build.yml@master + with: + GHA_REF: '' + GHA_BASE: ${{ github.event.before }} + + e2e: + needs: install-deps + if: inputs.RUN_E2E + permissions: + contents: read + uses: collaborationFactory/github-actions/.github/workflows/fe-e2e.yml@master + with: + GHA_REF: '' + GHA_BASE: ${{ github.event.before }} + secrets: + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + + release: + needs: [build, e2e] + # a skipped e2e (RUN_E2E: false) must not skip the release; a non-forced dispatch is a dry run + if: >- + !cancelled() + && needs.build.result == 'success' + && (needs.e2e.result == 'success' || needs.e2e.result == 'skipped') + && (github.event_name == 'push' || inputs.FORCE) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + previous_tag: ${{ steps.release.outputs.previous_tag }} + is_latest: ${{ steps.release.outputs.is_latest }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: '' + fetch-depth: 0 + token: ${{ secrets.GIT_USER_TOKEN }} + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.20.0 + + - name: Cache Node Modules + id: npm-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} + + - name: Use .npmrc + uses: collaborationFactory/github-actions/.github/actions/use-npmrc@master + with: + dot-npmrc: ${{ secrets.DOT_NPMRC }} + + - name: Install Modules + if: steps.npm-cache.outputs.cache-hit != 'true' + run: npm ci && echo "modules were installed via npm" || yarn + + - name: Configure git user + run: | + git config user.name ${{ secrets.GIT_USER }} + git config user.email ${{ secrets.GIT_USER_EMAIL }} + + - name: Tag and Push to Jfrog NPM Registry + id: release + uses: collaborationFactory/github-actions/.github/actions/artifacts@master + env: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + RELEASE_FLOW: true + FORCE: ${{ inputs.FORCE }} + + github-release: + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.release.outputs.tag }} + PREVIOUS_TAG: ${{ needs.release.outputs.previous_tag }} + IS_LATEST: ${{ needs.release.outputs.is_latest }} + run: | + if gh release view "$TAG" > /dev/null 2>&1; then + echo "A GitHub release for $TAG already exists, nothing to do" + exit 0 + fi + args=(--verify-tag --generate-notes "--latest=$IS_LATEST") + if [ -n "$PREVIOUS_TAG" ]; then + args+=(--notes-start-tag "$PREVIOUS_TAG") + fi + gh release create "$TAG" "${args[@]}" diff --git a/README.md b/README.md index ba742de9..112eaa8b 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,73 @@ JavaScript action runtime since June 2026). - `.github/dependabot.yml` keeps the SHA pins current on `master` and the active release branches (`release/25.2` through `release/26.2`) via per-branch `target-branch` entries; each branch gets one grouped Action-update PR per week. + +## FE Release Flow + +### What it does + +`.github/workflows/fe-release-flow.yml` releases a FE repo in **one run** per push to `release/*` +(or per manual dispatch): + +guard → install-deps → build ∥ e2e → release (tag + publish, one job) → GitHub release + +The `release` job computes the next `version/X.Y.Z` and checks whether any project is affected since +the previous tag of the release line. If so, it pushes the tag, then publishes all publishable +projects at that version. Tag before publish: if the tag push fails, the next run retries the same +version; if the publish fails after the tag was pushed, the version is lost and the next release is +the next patch (see Recovery). + +### Caller + +Copy `.github/workflow-templates/fe/fe-release.yml` to `.github/workflows/fe-release.yml` of the +consumer repo, with the `@release/X.Y` (or `@master`) ref of that branch. + +- **When migrating a branch, delete the old branch-push and tag-push caller files in the same + commit** (`fe-release.yml` as tagger plus `fe-tag-pushed.yml` / `on_tag_pushed.yaml` / + `tag_pushed.yml`). The release flow still pushes the tag with the PAT, so a tag-push workflow left + in the tree would publish a second time. +- Keep the file name `fe-release.yml`. The copy on the default branch is the seed for branch-off. +- Set `RUN_E2E: false` only for repos that do not run e2e before a release. + +### Triggers and `force` + +| Trigger | Branch | Behavior | +| ------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------- | +| push | `release/*` | build/e2e → release if a project is affected since the previous tag of the line (or first tag of the line) → GitHub release | +| dispatch, `force: true` | `release/*` | build/e2e → release (affected check bypassed) → GitHub release | +| dispatch, `force: true` | default branch | build/e2e → snapshot of all projects, no tag, no GitHub release | +| dispatch, `force: false` | either | build/e2e only (dry run) | + +Build and e2e always cover all projects. A dispatch on any other ref fails in `guard`. Every push +runs the flow, including upmerges, like in the backend repos. Only commits created by branch-off +(subject prefix `[branch-off]`) are never tagged or published. + +### Required secrets + +Passed with `secrets: inherit`: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, `DOT_NPMRC`, +`GIT_USER_TOKEN`, `GIT_USER`, `GIT_USER_EMAIL`, and optionally `PERCY_TOKEN`. The caller needs +`permissions: contents: write`. + +### Recovery + +- **Publish failed after the tag was pushed** (the job summary names the phase that failed): run a + **forced dispatch** on the branch. It releases the next patch; the failed version stays behind as + a dangling tag. "Re-run failed jobs" of a push run does not help: nothing is affected since the + tag that run already pushed, so it releases nothing. +- **Re-running only `github-release`** reuses the same tag and does nothing if the release already + exists. +- **A forced dispatch** releases even when no project is affected. +- **Warning: do not re-run a successful forced dispatch.** A re-run keeps `force: true`, so it cuts + another patch with identical content. Re-running a successful push run is harmless: nothing is + affected since the tag it pushed. + +### Concurrency + +One run per branch at a time, non-cancelling (`fe-release-${{ github.ref }}`). GitHub keeps only the +newest pending run. No change is lost when a pending run is cancelled: the run that executes checks +everything since the previous tag of the line. + +### GitHub release + +Created with `GITHUB_TOKEN`. The generated notes start at the previous tag of the same release line, +and "Latest" is set only for the highest `version/*` tag. diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md index ef432233..14729c40 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md @@ -4,12 +4,19 @@ Build the github-actions side of PFM-ISSUE-34283 as designed in [design.md](./design.md). The work adds a new reusable workflow, `fe-release-flow.yml`, which runs guard → install-deps → build ∥ e2e → release (version → tag → publish) → GitHub release in one run. It also adds: - a release-flow mode with `FORCE` and `$GITHUB_OUTPUT` values in the artifacts tool -- an explicit "all projects" mode in `run-many` +- a reachable "all projects" mode in `run-many` (empty ref, normalized in the script) - removal of the cancelling install-deps concurrency - a new caller template that replaces the two old ones - README documentation -The work is implemented on `release/25.4` and upmerged through `26.1 → 26.2 → 26.3 → 26.4 → master`, with each step rewriting refs and Node versions for its branch. +The work was first implemented on `release/25.4` with the plan to upmerge through `26.1 → 26.2 → 26.3 → 26.4 → master`. + +> **Plan change (2026-10-20, user decision during Phase 5):** the change targets **`master` only**. Phase 6 (the upmerge chain) is dropped. The Phase 1–4 work was carried onto `improvement/PFM-ISSUE-34283-Consolidate-the-FE-2-0-release-flow-into-a-master`, branched from `origin/master`. On that branch: +> - refs are `@master` and Node is `24.20.0` in `fe-release-flow.yml`, and the caller template uses `@master`; +> - `handle()` keeps the `[branch-off]` guard first, then the release-flow dispatch; +> - the `[branch-off]`-over-force test (planned for Phase 6) is added. +> +> Consequence: release lines `25.4`–`26.4` do not get `fe-release-flow.yml`. Consumer release branches keep the old two-run flow. The first line with the new flow is the next branch-off from master. A backport to older lines would be a separate cherry-pick effort. The original 25.4-based branch (spec commit only) is kept locally, untouched. ## Current State Analysis @@ -37,10 +44,10 @@ Verified on HEAD `faa4771` (= `origin/master`) and on `origin/release/25.4` (`e3 ## Desired End State -On `release/25.4` … `release/26.4` and `master`: +On `master` (originally: `release/25.4` … `release/26.4` and `master`): - `.github/workflows/fe-release-flow.yml` exists, with refs and Node version matching its branch. - The artifacts tool has a `RELEASE_FLOW` mode, and the composite action exposes `released`, `tag`, `previous_tag` and `is_latest`. -- `run-many` has an explicit `all` input, and `fe-build` / `fe-e2e` have `ALL_PROJECTS`. +- `run-many` normalizes an empty or undefined ref to `''`, which selects all projects. `fe-build` / `fe-e2e` / the `run-many` action are unchanged. - `fe-install-deps.yml` has no `concurrency:`. - `workflow-templates/fe/fe-release.yml` is the new caller, and `workflow-templates/fe/fe-tag-pushed.yml` is gone. - The README documents the flow. @@ -53,7 +60,8 @@ Verify with the per-phase greps below, `npx jest` (no failures beyond the Window - **An empty `GHA_REF` is handled in the scripts, not the workflows.** - All push callers pass `GHA_REF: ''` (`workflow-templates/fe/fe-main.yml:22`, `fe-release.yml:21`), and they keep doing so. - `run-many/action.yml:24` passes the arguments positionally and unquoted, so the empty ref arrives as `undefined` and `ref === ''` (`affected-projects.ts:28`) is unreachable today. - - The scripts normalize an empty or undefined ref to `''`. Once they do, `ref === ''` would be true for every push run, so it can't stay the "all projects" switch. "All" becomes an explicit input passed by env. + - **Decision (user, Phase 1):** keep `ref === ''` as the "all projects" switch and make it reachable by normalizing an empty or undefined ref to `''` in the script. No `all` input, no `ALL_PROJECTS`. In the workflows the ref stays empty, so `actions/checkout` behaves as before. + - Consequence: every caller that passes `GHA_REF: ''` (the `fe-main` and old `fe-release` templates) now builds and e2e-tests **all** projects on push. The user confirmed this restores the original intent: pushes to main/release build all projects, only PR runs (non-empty head ref) are affected. - **The composite action has to declare outputs.** `$GITHUB_OUTPUT` inside a composite action's `run` step belongs to that step. `.github/actions/artifacts/action.yml` needs an `id` on the ts-node step and an `outputs:` block, or the job can't read the values. - **`calculateNewVersion` mutates the tag object it returns** (`utils.ts:205`, `latestExistingTag.patchVersion(...)`). That object also sits in the tag list. `previous_tag` therefore has to be read as a string *before* the bump. - **The release job needs `!cancelled()` for the skipped-e2e case.** A job whose `if:` has no status function gets an implicit `success()`, so a skipped `needs` job (e2e with `RUN_E2E: false`) skips the dependent job. The `release` condition must start with `!cancelled()` and check `needs.*.result` explicitly. @@ -85,7 +93,7 @@ Every push, PR and merge to a shared branch is **[OUTWARD]**: the user approves ## Phase 1: Branch Setup and `run-many` "All Projects" Mode ### Overview -Create the feature branch from `release/25.4`, install actionlint, and commit the spec documents. Then make the existing "all projects" path in `run-many` reachable through an explicit input, with no change to today's default behavior. `fe-build` and `fe-e2e` get an additive `ALL_PROJECTS` input. +Create the feature branch from `release/25.4`, install actionlint, and commit the spec documents. Then make the existing "all projects" path in `run-many` (`ref === ''`) reachable by normalizing the ref in the script. Workflows and the composite action are unchanged. ### Changes Required: @@ -102,9 +110,7 @@ Commit the spec directory as the first commit (`PFM-ISSUE-34283 - Add research, #### 2. `run-many` logic **File**: `tools/scripts/run-many/affected-projects.ts` -**Changes**: -- Replace the `ref === ''` condition with an explicit `all` parameter. `ref` stays in the signature, because `run-many.ts` logs it. -- Add `normalizeRef`, which turns an empty or undefined ref (the workflows pass `GHA_REF: ''`) into `''`. It is exported so it can be unit-tested; `run-many.ts` runs `main()` on import and can't be. +**Changes**: Add `normalizeRef`, which turns an empty or undefined ref (the workflows pass `GHA_REF: ''`, which arrives as `undefined` because the positional arg is unquoted) into `''`. It is exported so it can be unit-tested; `run-many.ts` runs `main()` on import and can't be. `getAffectedProjects` keeps `ref === ''` as the "all projects" condition. ```ts export function normalizeRef(ref: string | undefined): string { @@ -112,103 +118,41 @@ export function normalizeRef(ref: string | undefined): string { } ``` -```ts -export function getAffectedProjects( - target: string, - jobIndex: number, - jobCount: number, - base: string, - ref: string, - all = false -) { - let allAffectedProjects = []; - if (all) { - allAffectedProjects = Utils.getAllProjects(false, null, target); - } else { - allAffectedProjects = Utils.getAllProjects(true, base, target); - } - // … unchanged -} -``` - **File**: `tools/scripts/run-many/run-many.ts` -**Changes**: Normalize the ref, read `RUN_MANY_ALL` from env, log it with the other inputs, and pass both on: -```ts -const ref = normalizeRef(process.argv[6]); // replaces `const ref = process.argv[6];` -const all = process.env.RUN_MANY_ALL === 'true'; -// add `,\n all ${all}` to the existing Inputs log line -const projects = getAffectedProjects(target, jobIndex, jobCount, base, ref, all); -``` - -#### 3. Composite action input -**File**: `.github/actions/run-many/action.yml` -**Changes**: Add an optional input. Pass it by env, so the positional arguments stay byte-identical. -```yaml - all: - description: 'Run the target for all projects instead of the affected ones' - required: false - default: 'false' -# … - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - RUN_MANY_ALL: ${{ inputs.all }} -``` +**Changes**: `const ref = normalizeRef(process.argv[6]);` (replaces `const ref = process.argv[6];`). -#### 4. Reusable build and e2e inputs -**Files**: `.github/workflows/fe-build.yml`, `.github/workflows/fe-e2e.yml` -**Changes**: Add the additive input. The default keeps today's behavior. -```yaml - ALL_PROJECTS: - type: boolean - required: false - default: false -``` -- Pass `all: ${{ inputs.ALL_PROJECTS }}` to the `run-many` step. -- Add `if: ${{ !inputs.ALL_PROJECTS }}` to the "Fetch base branch" step. A dispatch has no base, so `git fetch origin :` would otherwise run against an empty refspec. - -#### 5. Tests (new file: sample first) +#### 3. Tests (new file: sample first) **File**: `tools/scripts/run-many/affected-projects.test.ts` (new) -**Step 5a.** Write only the sample test, then stop for review of the pattern: +**Step 3a.** Write only the sample test, then stop for review of the pattern: ```ts -import { expect } from '@jest/globals'; -import { getAffectedProjects } from './affected-projects'; -import { Utils } from '../artifacts/utils'; - -afterEach(() => { - jest.restoreAllMocks(); -}); - -test('all=true lists all projects with the target, ignoring base and ref', () => { +test('an empty ref lists all projects with the target, ignoring base', () => { const getAllProjects = jest .spyOn(Utils, 'getAllProjects') .mockReturnValue(['app-a', 'lib-b', 'lib-c']); - const projects = getAffectedProjects('build', 1, 1, 'origin/release/26.4', '', true); + const projects = getAffectedProjects('build', 1, 1, 'origin/release/26.4', ''); expect(getAllProjects).toHaveBeenCalledWith(false, null, 'build'); expect(projects).toBe('app-a,lib-b,lib-c'); }); ``` -**Step 5b** (after the pattern is approved): +**Step 3b** (after the pattern is approved): - `normalizeRef(undefined)` and `normalizeRef('')` both return `''`, and `normalizeRef('feature/x')` returns `'feature/x'`. -- `all` omitted with `ref === ''` → `getAllProjects(true, base, target)`. This locks in today's effective behavior: a normalized empty ref still means affected. -- `all=false` with a non-empty ref → affected. +- A non-empty ref → `getAllProjects(true, base, target)` (affected). - Job slicing: 5 projects, `jobCount` 2 → index 1 gets the 1st, 3rd and 5th projects in sorted order; index 2 gets the 2nd and 4th. ### Success Criteria: #### Automated Verification: -- [ ] The branch is based on `origin/release/25.4`: `git merge-base --is-ancestor origin/release/25.4 HEAD` -- [ ] The new tests pass: `npx jest tools/scripts/run-many` -- [ ] The full suite fails only in the 5 Windows baseline tests: `npx jest 2>&1 | grep -E '^\s+● ' | grep -v Console | sort -u` lists exactly those 5 names -- [ ] The positional args are unchanged: `grep -c 'run-many.ts" ${{ inputs.target }} ${{ inputs.jobIndex }} ${{ inputs.jobCount }} ${{ inputs.base }} ${{ inputs.ref }}$' .github/actions/run-many/action.yml` = 1 -- [ ] `ALL_PROJECTS` is wired: `grep -c 'all: ${{ inputs.ALL_PROJECTS }}' .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` = 1 per file -- [ ] actionlint reports no new findings for `fe-build.yml` / `fe-e2e.yml` compared with the baseline -- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 1 complete. Summary: branch created off release/25.4, specs committed, actionlint installed; run-many has an explicit `all` input (env RUN_MANY_ALL), fe-build/fe-e2e have ALL_PROJECTS; affected-projects tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 2." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. +- [x] The branch is based on `origin/release/25.4`: `git merge-base --is-ancestor origin/release/25.4 HEAD` +- [x] The new tests pass: `npx jest tools/scripts/run-many` +- [x] The full suite fails only in the 5 Windows baseline tests: `npx jest 2>&1 | grep -E '^\s+● ' | grep -v Console | sort -u` lists exactly those 5 names (8 failing tests in total, because some baseline tests run in more than one file; the same 8 fail without this change) +- [x] The action and the build/e2e workflows are unchanged: `git diff --quiet origin/release/25.4 -- .github/actions/run-many .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 1 complete. Summary: branch created off release/25.4, specs committed, actionlint installed; run-many normalizes an empty/undefined ref to '' so `ref === ''` selects all projects (no new inputs); affected-projects tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 2." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: -- [ ] The sample test pattern was approved before the remaining tests were written -- [ ] Review: no existing caller passes `all`, so PR, main and release runs keep affected behavior +- [x] The sample test pattern was approved before the remaining tests were written (reviewed in conversation; remaining tests written after the user confirmed the ref-based approach) +- [x] Review: callers passing `GHA_REF: ''` (`fe-main`, old `fe-release` templates) now run all projects on push (confirmed as the original intent); PR runs (non-empty head ref) keep affected behavior --- @@ -406,16 +350,16 @@ Also add to `utils.test.ts`: `calculateNewVersion(branch, tags)` with a precompu ### Success Criteria: #### Automated Verification: -- [ ] Release-flow tests pass: `npx jest tools/scripts/artifacts/artifacts-handler-release-flow.test.ts` -- [ ] Existing mode tests pass unchanged: `npx jest tools/scripts/artifacts/artifacts-handler-tag.test.ts tools/scripts/artifacts/artifacts-handler.test.ts` -- [ ] The full suite fails only in the 5 Windows baseline tests (same command as Phase 1) -- [ ] TypeScript compiles: `npx tsc --noEmit -p tsconfig.json` -- [ ] Composite outputs are declared: `grep -c 'steps.handler.outputs' .github/actions/artifacts/action.yml` = 4 -- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 2 complete. Summary: ArtifactsHandler has a RELEASE_FLOW mode (version → affected check unless FORCE → git tag/push → publish all at that version → outputs released/tag/previous_tag/is_latest; forced default branch = all-projects snapshot); artifacts action exposes the outputs; release-flow tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 3." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. +- [x] Release-flow tests pass: `npx jest tools/scripts/artifacts/artifacts-handler-release-flow.test.ts` +- [x] Existing mode tests pass unchanged: `npx jest tools/scripts/artifacts/artifacts-handler-tag.test.ts tools/scripts/artifacts/artifacts-handler.test.ts` (the only failures are the 3 Windows baseline tests from `utils.test.ts`, which the tag test imports) +- [x] The full suite fails only in the 5 Windows baseline tests (same command as Phase 1) +- [x] TypeScript compiles: `npx tsc --noEmit -p tsconfig.json` +- [x] Composite outputs are declared: `grep -c 'steps.handler.outputs' .github/actions/artifacts/action.yml` = 4 +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 2 complete. Summary: ArtifactsHandler has a RELEASE_FLOW mode (version → affected check unless FORCE → git tag/push → publish all at that version → outputs released/tag/previous_tag/is_latest; forced default branch = all-projects snapshot); artifacts action exposes the outputs; release-flow tests pass (sample reviewed first). Please review the changes and reply 'yes' to continue to Phase 3." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: -- [ ] The sample test pattern was approved before the remaining tests were written -- [ ] Review: `bumpVersionForReleaseBranch()` and the constructor's mode selection are unchanged (`git diff origin/release/25.4 -- tools/scripts/artifacts/artifacts-handler.ts` shows only additions, plus the `handle()` dispatch) +- [x] The sample test pattern was approved before the remaining tests were written +- [x] Review: `bumpVersionForReleaseBranch()` and the constructor's mode selection are unchanged (`git diff origin/release/25.4 -- tools/scripts/artifacts/artifacts-handler.ts` shows only additions, plus the `handle()` dispatch) --- @@ -468,8 +412,6 @@ env: jobs: guard: - # bot pushes (upmerge, version commits) never release - if: github.event_name != 'push' || github.event.pusher.name != 'squad-fe' runs-on: ubuntu-latest permissions: {} steps: @@ -502,7 +444,6 @@ jobs: with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} - ALL_PROJECTS: ${{ github.event_name == 'workflow_dispatch' }} e2e: needs: install-deps @@ -513,7 +454,6 @@ jobs: with: GHA_REF: '' GHA_BASE: ${{ github.event.before }} - ALL_PROJECTS: ${{ github.event_name == 'workflow_dispatch' }} secrets: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} @@ -597,6 +537,8 @@ jobs: gh release create "$TAG" "${args[@]}" ``` +**Note (Phase 1 decision):** `GHA_REF: ''` means "all projects", so build and e2e cover all projects on both push and dispatch, as intended. Whether a push *releases* is still decided by the tool's own affected check (`BASE`), unless `FORCE`. + #### 2. Remove the install-deps concurrency **File**: `.github/workflows/fe-install-deps.yml` **Changes**: Delete lines 16-18 (`concurrency:` / `group:` / `cancel-in-progress:`). @@ -642,17 +584,25 @@ jobs: ### Success Criteria: #### Automated Verification: -- [ ] actionlint is clean for the new and changed files: `actionlint .github/workflows/fe-release-flow.yml .github/workflows/fe-install-deps.yml .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` -- [ ] The caller template lints as a workflow: `actionlint .github/workflow-templates/fe/fe-release.yml` -- [ ] No reusable FE workflow declares concurrency except `fe-licenses.yml`: `grep -l '^\s*concurrency:' .github/workflows/fe-*.yml` = `fe-licenses.yml` only -- [ ] All internal refs in the new files point to this branch: `grep -o 'github-actions/.github/[a-z]*/[a-z-]*\(.yml\)\?@[^ ]*' .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml | grep -vc '@release/25.4$'` = 0 -- [ ] The old reusables are untouched: `git diff --quiet origin/release/25.4 -- .github/workflows/fe-tag.yml .github/workflows/fe-release.yml` -- [ ] The tag-pushed template is gone: `test ! -e .github/workflow-templates/fe/fe-tag-pushed.yml` -- [ ] The full jest suite fails only in the 5 Windows baseline tests -- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 3 complete. Summary: fe-release-flow.yml added (guard → install-deps → build ∥ e2e → release → github-release), install-deps concurrency removed, caller template replaced (push release/* + dispatch force, run-level non-cancelling concurrency, secrets: inherit), fe-tag-pushed template deleted; actionlint clean. Please review the changes and reply 'yes' to continue to Phase 4." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. +- [x] actionlint is clean for the new and changed files: `actionlint .github/workflows/fe-release-flow.yml .github/workflows/fe-install-deps.yml .github/workflows/fe-build.yml .github/workflows/fe-e2e.yml` +- [x] The caller template lints as a workflow: `actionlint .github/workflow-templates/fe/fe-release.yml` +- [x] No reusable FE workflow declares concurrency except `fe-licenses.yml`: `grep -l '^\s*concurrency:' .github/workflows/fe-*.yml` = `fe-licenses.yml` only +- [x] All internal refs in the new files point to this branch: `grep -o 'github-actions/.github/[a-z]*/[a-z-]*\(.yml\)\?@[^ ]*' .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml | grep -vc '@release/25.4$'` = 0 (the `[a-z-]*` class skips `fe-e2e.yml`; with `[a-z0-9-]*` it is also 0) +- [x] Nested wiring: actionlint on a temporary copy of `fe-release-flow.yml` with the reusable refs rewritten to `./.github/workflows/…` is clean (actionlint cannot resolve remote `uses:` refs) +- [x] The old reusables are untouched: `git diff --quiet origin/release/25.4 -- .github/workflows/fe-tag.yml .github/workflows/fe-release.yml` +- [x] The tag-pushed template is gone: `test ! -e .github/workflow-templates/fe/fe-tag-pushed.yml` +- [x] The full jest suite fails only in the 5 Windows baseline tests +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 3 complete. Summary: fe-release-flow.yml added (guard → install-deps → build ∥ e2e → release → github-release), install-deps concurrency removed, caller template replaced (push release/* + dispatch force, run-level non-cancelling concurrency, secrets: inherit), fe-tag-pushed template deleted; actionlint clean. Please review the changes and reply 'yes' to continue to Phase 4." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: -- [ ] Walk through the trigger table in design.md against the `if:` conditions: push on `release/*`; dispatch with force on `release/*`; dispatch with force on the default branch; dispatch without force; dispatch on a feature branch or tag; `squad-fe` push; `RUN_E2E: false` +- [x] Walk through the trigger table in design.md against the `if:` conditions: push on `release/*`; dispatch with force on `release/*`; dispatch with force on the default branch; dispatch without force; dispatch on a feature branch or tag; `squad-fe` push; `RUN_E2E: false` + - push `release/*`: guard passes → build/e2e on all projects (Phase 1 decision; design.md row 1 says affected) → release runs, the tool's affected check (BASE) decides → GitHub release only if `released` + - dispatch + force, `release/*`: release runs, the affected check is bypassed → GitHub release + - dispatch + force, default branch: the tool publishes a snapshot, `released=false` → no GitHub release + - dispatch without force: `release` is skipped (dry run) + - dispatch on a feature branch or tag: `guard` fails → install-deps, build, e2e are skipped → `needs.build.result != 'success'` → release is skipped + - `squad-fe` push: ~~`guard` is skipped → all dependents are skipped~~ — the skip was removed in Phase 5 (user decision). A `squad-fe` push now runs like any other push. + - `RUN_E2E: false`: e2e is skipped → `release` still runs thanks to `!cancelled()` plus the explicit `skipped` check --- @@ -668,7 +618,7 @@ Document the release flow for consumers. The caller no longer lists secrets (`se **Changes**: Add a `## FE Release Flow` section after "Node 24 Compatibility" with these subsections: - **What it does**: one run per push to `release/*` or dispatch. Order: guard → install-deps → build ∥ e2e → tag + publish (one job) → GitHub release. Tag before publish: a failed tag push retries the same version, and a failed publish moves the next run to the next patch. - **Caller**: copy `.github/workflow-templates/fe/fe-release.yml`. When migrating a branch, delete the old branch-push and tag-push caller files in the same commit (double-publish warning). The caller file name is `fe-release.yml`. The copy on the default branch is the seed for branch-off. Set `RUN_E2E: false` only for repos without e2e on release. -- **Triggers and `force`**: the four-row trigger table from design.md. A dispatch on any other ref fails in `guard`. `squad-fe` pushes are skipped. +- **Triggers and `force`**: the four-row trigger table from design.md. A dispatch on any other ref fails in `guard`. ~~`squad-fe` pushes are skipped.~~ Every push runs the flow (Phase 5 decision). - **Required secrets** (via `secrets: inherit`): `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, `DOT_NPMRC`, `GIT_USER_TOKEN`, `GIT_USER`, `GIT_USER_EMAIL`, optional `PERCY_TOKEN`. Permissions: `contents: write`. - **Recovery**: - "Re-run failed jobs" on `release` publishes and releases the next patch, leaving the failed version as a dangling tag. @@ -680,51 +630,70 @@ Document the release flow for consumers. The caller no longer lists secrets (`se ### Success Criteria: #### Automated Verification: -- [ ] The section exists: `grep -c '^## FE Release Flow' README.md` = 1 -- [ ] Every secret declared by the workflow is documented: `for s in $(sed -n '/^ secrets:/,/^env:/p' .github/workflows/fe-release-flow.yml | grep -oE '^ [A-Z_]+' | tr -d ' '); do grep -q "$s" README.md || echo "missing $s"; done` prints nothing -- [ ] Prettier is clean on the touched files: `npx prettier --check README.md .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml .github/actions tools/scripts/run-many tools/scripts/artifacts` -- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 4 complete. Summary: README has an 'FE Release Flow' section (flow, caller migration incl. double-publish warning, trigger/force table, required secrets, recovery, concurrency, GitHub release). Please review README.md and reply 'yes' to continue to Phase 5." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. +- [x] The section exists: `grep -c '^## FE Release Flow' README.md` = 1 +- [x] Every secret declared by the workflow is documented: `for s in $(sed -n '/^ secrets:/,/^env:/p' .github/workflows/fe-release-flow.yml | grep -oE '^ [A-Z_]+' | tr -d ' '); do grep -q "$s" README.md || echo "missing $s"; done` prints nothing. As written, the `sed` range also catches the job-level `secrets:` blocks and so lists the `with:` inputs `GHA_REF` / `GHA_BASE`; restricted to the `workflow_call` block (`sed -n '/^on:/,/^env:/p' … | sed -n '/^ secrets:/,$p'`) every secret is found +- [x] Prettier is clean on the touched files: `npx prettier --check README.md .github/workflows/fe-release-flow.yml .github/workflow-templates/fe/fe-release.yml .github/actions tools/scripts/run-many tools/scripts/artifacts` — run with `--end-of-line auto` (the Windows checkout is CRLF). The remaining warnings are in code that predates this change, on base too: `run-many.ts:13` (`if(`), `utils.ts:37,63` (long lines), `nx-project.ts` (untouched). These are left as they are +- [x] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 4 complete. Summary: README has an 'FE Release Flow' section (flow, caller migration incl. double-publish warning, trigger/force table, required secrets, recovery, concurrency, GitHub release). Please review README.md and reply 'yes' to continue to Phase 5." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: - [ ] The README reads correctly for someone who migrates a consumer repo without having read design.md --- -## Phase 5: Review and PR into `release/25.4` +## Phase 5: Review and PR into `master` ### Overview -Self-review the branch, then open the implementation PR against `release/25.4`. The PR must not target master; default detection would guess wrong. +Self-review the branch, then open the implementation PR against `master` (changed from `release/25.4`, see the plan change in the Overview). ### Changes Required: #### 1. Self-review -Run `/review:branch-merge` (or the `code-review` skill) over `origin/release/25.4..HEAD`. Fix only the findings this change caused. +Run `/review:branch-merge` (or the `code-review` skill) over `origin/master..HEAD`. Fix only the findings this change caused. + +**Self-review outcome** (`code-review`, medium, run on the 25.4-based diff; the code is the same on master apart from the guard): +- Fixed: the new `utils.test.ts` precomputed-tags case now mocks `execSync` instead of only spying on it. +- Already decided / accepted: + - `normalizeRef` makes `fe-main` pushes run all projects (Phase 1 decision) + - install-deps concurrency removal (design trade-off 4) + - tag before publish, with a partial publish leaving a dangling version (design) + - the release job duplicates the setup steps of `fe-tag.yml` / `fe-release.yml` (existing pattern) +- Not changed: + - on dispatch, run-many logs base `origin/undefined` and the fetch step runs `git fetch origin :` (harmless: an empty ref means all projects) + - a double affected computation in the release mode (minor) + - `contents: read` on build/e2e (the reusables do not use `GITHUB_TOKEN`) +- **Open, for the user:** + 1. ~~The `squad-fe` guard also skips upmerges that carry real changes.~~ **Resolved (user decision):** the `squad-fe` skip is removed from `fe-release-flow.yml`, overriding research Q8 / design.md:53,172. It existed only in the cplace-fe product repos' callers, and in the backend repos every push to a release/master branch publishes. Every push now runs the flow; the affected check (or `force`) and the `[branch-off]` guard decide whether to release. The README is updated. + 2. ~~GitHub keeps only the newest pending run, so a cancelled queued push's changes can fall outside the next run's `BASE`.~~ **Resolved (user decision, option b):** the non-forced affected check now uses the previous tag of the line as base (`this.base = previousTag`) instead of `github.event.before`. `BASE` and `DEFAULT_BRANCH` are removed from the release job's env. With no previous tag (X.Y.1), the check is skipped and the first version is released, as before. The sample test asserts base `version/26.4.2`; the first-tag test asserts no affected call. This deviates from design.md, which rejected tag-based base only for the forced path. + - Consequence for recovery: "Re-run failed jobs" after a failed publish no longer releases, because nothing is affected since the dangling tag. Recovery is now a **forced dispatch**. Re-running a successful push run is harmless. The README Recovery section and the tool's step summary are updated. + 3. ~~(README) Warn that "Re-run all jobs" after a successful release cuts another patch.~~ **Resolved:** with the tag-based base, this applies only to re-running a successful **forced dispatch** (`force: true` is kept). The README warns about exactly that. #### 2. PR [OUTWARD] Push the branch and open the PR only after explicit user approval. -- Base: `release/25.4`. Title: `PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow`. +- Base: `master`. Title: `PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow`. - Body: - - the building blocks (run-many `all`, tool release-flow mode + outputs, `fe-release-flow.yml`, install-deps concurrency removal, new caller template, README) + - the building blocks (run-many empty-ref normalization, tool release-flow mode + outputs, `fe-release-flow.yml`, install-deps concurrency removal, new caller template, README) - the link to PFM-ISSUE-34283 - the "not in scope" list - a note that live validation is pending or manual - - a note that the upmerges follow after merge + - a note that the change is master-only (older release lines keep the old flow) ### Success Criteria: #### Automated Verification: -- [ ] The PR targets `release/25.4`: `gh pr view --json baseRefName --jq .baseRefName` = `release/25.4` +- [ ] The PR targets `master`: `gh pr view --json baseRefName --jq .baseRefName` = `master` - [ ] The PR's required checks (`pr-checks.yml`) pass: `gh pr checks` - [ ] The full jest suite fails only in the 5 Windows baseline tests -- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 5 complete. Summary: self-review done (findings fixed or listed), PR # open against release/25.4. Please review the PR, and reply 'yes' once it is merged to continue to Phase 6 (upmerge chain)." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. +- [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 5 complete. Summary: self-review done (findings fixed or listed), PR # open against master. Please review the PR, and reply 'yes' once it is merged to close out the plan." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: - [ ] The live validation from "Manual Testing Steps" was done or consciously deferred before merge -- [ ] The PR is reviewed and merged into `release/25.4` +- [ ] The PR is reviewed and merged into `master` --- -## Phase 6: Upmerge Chain `26.1 → 26.2 → 26.3 → 26.4 → master` +## Phase 6: Upmerge Chain `26.1 → 26.2 → 26.3 → 26.4 → master` — DROPPED + +> Dropped on 2026-10-20 (user decision): the change targets `master` directly. The master-specific parts of this phase were done while moving the work onto master: `@master` refs, Node `24.20.0`, the `[branch-off]` guard first in `handle()`, the `[branch-off]`-over-force test, and `isBranchOffCommit` mocked to `false` in `mockBranch()`. The checklist below is kept for reference only. ### Overview Carry the merged change up the chain as local merge commits (the repo's existing upmerge practice). Before committing each merge, rewrite the new file's refs and Node version for the target branch. On `26.4`, where the `[branch-off]` guard exists, resolve `handle()` so the guard runs first and add the test that it wins over force. Every push is **[OUTWARD]** and executed by the user after approval. @@ -773,7 +742,7 @@ Then commit the merge with git's default message (`Merge remote-tracking branch ## Testing Strategy ### Unit Tests: -- `affected-projects.test.ts`: explicit `all`, ref normalization (empty or undefined → `''`), the unchanged default when `ref === ''`, and job slicing. +- `affected-projects.test.ts`: ref normalization (empty or undefined → `''`), empty ref → all projects, non-empty ref → affected, and job slicing. - `artifacts-handler-release-flow.test.ts`: - tag/push commands, and the version passed to publish - publish happens after the push diff --git a/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts new file mode 100644 index 00000000..9cf545e8 --- /dev/null +++ b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts @@ -0,0 +1,272 @@ +import { expect } from '@jest/globals'; +import * as child_process from 'child_process'; +import { execSync } from 'child_process'; +import { ArtifactsHandler, TASK } from './artifacts-handler'; +import { NxProject, NxProjectKind } from './nx-project'; +import { Utils } from './utils'; + +process.env.JFROG_BASE64_TOKEN = 'jfrog_base64_token'; +process.env.JFROG_URL = 'jfrog_url'; +process.env.JFROG_USER = 'jfrog_user'; + +const rootDir = execSync(`git rev-parse --show-toplevel`).toString().trim(); + +const lsRemote = [ + 'a1\trefs/tags/version/25.4.9', + 'a2\trefs/tags/version/26.3.5', + 'a3\trefs/tags/version/26.4.1', + 'a4\trefs/tags/version/26.4.2', +].join('\n'); + +afterEach(() => { + jest.restoreAllMocks(); + delete process.env.RELEASE_FLOW; + delete process.env.FORCE; + process.env.BASE = ''; + process.env.TAG = ''; + process.env.ONLY_BUMP_VERSION = 'false'; + process.env.PR_NUMBER = ''; +}); + +test('a push to a release branch with affected projects tags, then publishes all projects at that version', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + const affected = jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + // affected since the previous release of the line, not since the pushed range + expect(affected).toHaveBeenCalledWith( + 'version/26.4.2', + NxProjectKind.Library, + TASK.RELEASE, + expect.anything(), + '@cplace-frontend-applications' + ); + expect(execMock).toHaveBeenCalledWith('git tag version/26.4.3'); + expect(execMock).toHaveBeenCalledWith('git push origin version/26.4.3'); + expect(allProjects).toHaveBeenCalledWith( + TASK.RELEASE, + expect.objectContaining({ major: 26, minor: 4, patch: 3 }), + '@cplace-frontend-applications' + ); + expect(pushCallOrder(execMock)).toBeLessThan( + allProjects.mock.invocationCallOrder[0] + ); + expect(output).toHaveBeenCalledWith('tag', 'version/26.4.3'); + expect(output).toHaveBeenCalledWith('previous_tag', 'version/26.4.2'); + expect(output).toHaveBeenCalledWith('is_latest', 'true'); + expect(output).toHaveBeenLastCalledWith('released', 'true'); +}); + +test('a push to a release branch with nothing affected neither tags nor publishes', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest.spyOn(Utils, 'getAffectedNxProjects').mockReturnValue([]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(allProjects).not.toHaveBeenCalled(); + expect(output.mock.calls).toEqual([['released', 'false']]); +}); + +test('a forced run on a release branch tags and publishes without running nx affected', async () => { + process.env.RELEASE_FLOW = 'true'; + process.env.FORCE = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + const affected = jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(affected).not.toHaveBeenCalled(); + expect(gitTagOrPushCalls(execMock)).toEqual([ + 'git tag version/26.4.3', + 'git push origin version/26.4.3', + ]); + expect(allProjects).toHaveBeenCalledTimes(1); + expect(output).toHaveBeenLastCalledWith('released', 'true'); +}); + +test('the first push of a new release line tags X.Y.1 even if nothing is affected', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.5'); + const affected = jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(affected).not.toHaveBeenCalled(); + expect(gitTagOrPushCalls(execMock)).toEqual([ + 'git tag version/26.5.1', + 'git push origin version/26.5.1', + ]); + expect(allProjects).toHaveBeenCalledWith( + TASK.RELEASE, + expect.objectContaining({ major: 26, minor: 5, patch: 1 }), + '@cplace-frontend-applications' + ); + expect(output).toHaveBeenCalledWith('previous_tag', ''); + expect(output).toHaveBeenCalledWith('is_latest', 'true'); + expect(output).toHaveBeenLastCalledWith('released', 'true'); +}); + +test('a release on an older line is not the latest and refers to its own previous tag', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.3'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(execMock).toHaveBeenCalledWith('git tag version/26.3.6'); + expect(output).toHaveBeenCalledWith('tag', 'version/26.3.6'); + expect(output).toHaveBeenCalledWith('previous_tag', 'version/26.3.5'); + expect(output).toHaveBeenCalledWith('is_latest', 'false'); +}); + +test('a failed tag push fails the run before anything is published', async () => { + process.env.RELEASE_FLOW = 'true'; + jest + .spyOn(child_process, 'execSync') + .mockImplementation((command: string) => { + if (command.startsWith('git push')) throw new Error('push rejected'); + return Buffer.from( + command.startsWith('git ls-remote --tags') ? lsRemote : '' + ); + }); + mockBranch('release/26.4'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await expect(new ArtifactsHandler().handle()).rejects.toThrow( + 'push rejected' + ); + + expect(allProjects).not.toHaveBeenCalled(); + expect(output).not.toHaveBeenCalledWith('released', 'true'); +}); + +test('a forced run on the default branch publishes a snapshot of all projects without a tag', async () => { + process.env.RELEASE_FLOW = 'true'; + process.env.FORCE = 'true'; + const execMock = mockGit(); + mockBranch('master'); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(allProjects).toHaveBeenCalledTimes(1); + const [task, version] = allProjects.mock.calls[0]; + expect(task).toBe(TASK.MAIN_SNAPSHOT); + expect(version.toString()).toContain(`-${ArtifactsHandler.SNAPSHOT}-`); + expect(output.mock.calls).toEqual([['released', 'false']]); +}); + +test('a run on the default branch without force does nothing', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('master'); + const affected = jest.spyOn(Utils, 'getAffectedNxProjects'); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects'); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(affected).not.toHaveBeenCalled(); + expect(allProjects).not.toHaveBeenCalled(); + expect(output.mock.calls).toEqual([['released', 'false']]); +}); + +test('the old version bump mode writes no outputs', async () => { + process.env.ONLY_BUMP_VERSION = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(execMock).toHaveBeenCalledWith('git push origin version/26.4.3'); + expect(output).not.toHaveBeenCalled(); +}); + +test('a [branch-off] tip commit wins over a forced release flow', async () => { + process.env.RELEASE_FLOW = 'true'; + process.env.FORCE = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest.spyOn(Utils, 'isBranchOffCommit').mockReturnValue(true); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects'); + + await new ArtifactsHandler().handle(); + + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(allProjects).not.toHaveBeenCalled(); +}); + +function gitTagOrPushCalls(execMock: jest.SpyInstance) { + return execMock.mock.calls + .map(([command]) => String(command)) + .filter( + (command) => + command.startsWith('git tag') || command.startsWith('git push') + ); +} + +function mockGit() { + return jest + .spyOn(child_process, 'execSync') + .mockImplementation((command: string) => + Buffer.from(command.startsWith('git ls-remote --tags') ? lsRemote : '') + ); +} + +function mockBranch(branch: string) { + jest + .spyOn(Utils, 'getCurrentBranchNameFromGithubEnv') + .mockReturnValue(branch); + jest + .spyOn(Utils, 'parseScopeFromPackageJson') + .mockReturnValue('@cplace-frontend-applications'); + jest.spyOn(Utils, 'getRootDir').mockReturnValue(rootDir); + jest.spyOn(Utils, 'initGithubActionsFile').mockReturnValue(); + jest.spyOn(Utils, 'appendStepSummary').mockReturnValue(); + jest.spyOn(Utils, 'isBranchOffCommit').mockReturnValue(false); +} + +function pushCallOrder(execMock: jest.SpyInstance) { + const index = execMock.mock.calls.findIndex(([command]) => + String(command).startsWith('git push') + ); + return execMock.mock.invocationCallOrder[index]; +} diff --git a/tools/scripts/artifacts/artifacts-handler.ts b/tools/scripts/artifacts/artifacts-handler.ts index cd886266..d67ba8fb 100644 --- a/tools/scripts/artifacts/artifacts-handler.ts +++ b/tools/scripts/artifacts/artifacts-handler.ts @@ -30,6 +30,8 @@ export class ArtifactsHandler { public task: TASK = TASK.MAIN_SNAPSHOT; private onlyDeleteArtifacts = false; private onlyBumpVersion = false; + private releaseFlow = false; + private force = false; private prNumber = ''; private currentBranch = ''; private jfrogCredentials: JfrogCredentials; @@ -47,6 +49,10 @@ export class ArtifactsHandler { this.onlyDeleteArtifacts = JSON.parse(process.env.ONLY_DELETE_ARTIFACTS); if (process.env.ONLY_BUMP_VERSION != undefined) this.onlyBumpVersion = JSON.parse(process.env.ONLY_BUMP_VERSION); + if (process.env.RELEASE_FLOW != undefined) + this.releaseFlow = JSON.parse(process.env.RELEASE_FLOW); + if (process.env.FORCE != undefined) + this.force = JSON.parse(process.env.FORCE); if (process.env.SNAPSHOT != undefined) this.isSnapshot = JSON.parse(process.env.SNAPSHOT); this.base = process.env.BASE != undefined ? process.env.BASE : 'main'; @@ -112,7 +118,9 @@ export class ArtifactsHandler { ); return Promise.resolve(); } - if ( + if (this.releaseFlow) { + await this.tagAndPublishRelease(); + } else if ( this.onlyBumpVersion && this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX) ) { @@ -205,4 +213,90 @@ export class ArtifactsHandler { ); } } + + private async tagAndPublishRelease() { + Utils.writeOutput('released', 'false'); + if ( + !this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX) + ) { + if (!this.force) { + console.log( + `Release flow on ${this.currentBranch} without force: nothing is tagged or published` + ); + return; + } + console.log( + `Forced release flow on ${this.currentBranch}: publishing a snapshot of all projects` + ); + this.onlyAffected = false; + this.initProjects(); + await this.buildAndPublishProjects(); + Utils.appendStepSummary( + `Published a snapshot of all projects as \`${this.currentVersion}\` (no tag, no GitHub release)` + ); + return; + } + + const allTags = Utils.getAllVersionTagsInAscendingOrder(); + // read before calculateNewVersion: it bumps the returned tag object in place + const previous = Utils.getLatestTagForReleaseBranch( + this.currentBranch, + Utils.getVersionFromReleaseBranch(this.currentBranch), + allTags + ); + const previousTag = previous.isValid() ? previous.getGitTag() : ''; + this.calculatedNewVersion = Utils.calculateNewVersion( + this.currentBranch, + allTags + ); + const isLatest = allTags.every( + (tag) => !tag.isValid() || this.calculatedNewVersion.compareTo(tag) >= 0 + ); + console.log( + `The calculated new tag/version for branch ${this.currentBranch} is ${this.calculatedNewVersion}` + ); + + if (this.force) { + console.log('Force is set: skipping the affected check'); + } else if (previousTag === '') { + console.log( + `No tag exists yet for ${this.currentBranch}: releasing the first version of the line` + ); + } 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; + console.log(`Checking affected projects since ${previousTag}`); + this.initAffectedProjects(); + if (this.projects.length === 0) { + console.log( + `No projects are affected since ${previousTag} therefore no new Patch version is needed` + ); + return; + } + } + + const gitTag = this.calculatedNewVersion.getGitTag(); + console.log(`Tag phase: pushing ${gitTag}`); + console.log(execSync(`git tag ${gitTag}`).toString()); + console.log(execSync(`git push origin ${gitTag}`).toString()); + Utils.writeOutput('tag', gitTag); + Utils.writeOutput('previous_tag', previousTag); + Utils.writeOutput('is_latest', String(isLatest)); + Utils.appendStepSummary( + `Tag phase: pushed \`${gitTag}\`. Publish phase started; if this run failed after this line, the tag is dangling: run a forced dispatch on this branch to release the next patch.` + ); + + console.log( + `Publish phase: publishing all publishable projects as ${this.calculatedNewVersion}` + ); + this.currentVersion = this.calculatedNewVersion; + this.onlyAffected = false; + this.initProjects(); + await this.buildAndPublishProjects(); + Utils.writeOutput('released', 'true'); + Utils.appendStepSummary( + `Publish phase: published all publishable projects as \`${this.calculatedNewVersion}\`` + ); + } } diff --git a/tools/scripts/artifacts/utils.test.ts b/tools/scripts/artifacts/utils.test.ts index e628726f..da70f63c 100644 --- a/tools/scripts/artifacts/utils.test.ts +++ b/tools/scripts/artifacts/utils.test.ts @@ -252,6 +252,19 @@ test('can bump version correctly for a release Branch with existing tag', () => expect(version).toStrictEqual(new Version('22.4.13')); }); +test('can bump version from a precomputed tag list without listing the remote tags', () => { + const execMock = jest + .spyOn(child_process, 'execSync') + .mockReturnValue(Buffer.from('')); + const version = Utils.calculateNewVersion('release/22.4', [ + new Version('22.3.7'), + new Version('22.4.11'), + new Version('22.4.12'), + ]); + expect(version).toStrictEqual(new Version('22.4.13')); + expect(execMock).not.toHaveBeenCalled(); +}); + test('can create hashed timestamp correctly formatted', () => { const timestamp = Utils.getHashedTimestamp(); expect(timestamp).toEqual('lccjqzk0-20230101'); diff --git a/tools/scripts/artifacts/utils.ts b/tools/scripts/artifacts/utils.ts index 9498be94..4a4bdf79 100644 --- a/tools/scripts/artifacts/utils.ts +++ b/tools/scripts/artifacts/utils.ts @@ -1,3 +1,4 @@ +import * as core from '@actions/core'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -174,11 +175,12 @@ export class Utils { public static getLatestTagForReleaseBranch( branch: string, - versionOfBranch: Version + versionOfBranch: Version, + allTags?: Version[] ): Version { let tags: Version[] = []; if (branch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX)) { - tags = this.getAllVersionTagsInAscendingOrder() + tags = (allTags ?? this.getAllVersionTagsInAscendingOrder()) .filter( (version) => version.major === versionOfBranch.major && @@ -192,11 +194,15 @@ export class Utils { return new Version(''); } - public static calculateNewVersion(branch: string): Version { + public static calculateNewVersion( + branch: string, + allTags?: Version[] + ): Version { const versionOfBranch: Version = Utils.getVersionFromReleaseBranch(branch); const latestExistingTag = Utils.getLatestTagForReleaseBranch( branch, - versionOfBranch + versionOfBranch, + allTags ); if (!latestExistingTag.isValid()) { versionOfBranch.patch = 1; @@ -342,6 +348,17 @@ export class Utils { } } + public static writeOutput(name: string, value: string) { + console.log(`Output ${name}=${value}`); + core.setOutput(name, value); + } + + public static appendStepSummary(markdown: string) { + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`); + } + } + public static writePublishedProjectToGithubCommentsFile(message: string) { const gitHubCommentsFile = Utils.getGitHubCommentsFile(); const currentDate = new Date(); diff --git a/tools/scripts/run-many/affected-projects.test.ts b/tools/scripts/run-many/affected-projects.test.ts new file mode 100644 index 00000000..9af171a3 --- /dev/null +++ b/tools/scripts/run-many/affected-projects.test.ts @@ -0,0 +1,64 @@ +import { expect } from '@jest/globals'; +import { getAffectedProjects, normalizeRef } from './affected-projects'; +import { Utils } from '../artifacts/utils'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('an empty ref lists all projects with the target, ignoring base', () => { + const getAllProjects = jest + .spyOn(Utils, 'getAllProjects') + .mockReturnValue(['app-a', 'lib-b', 'lib-c']); + + const projects = getAffectedProjects( + 'build', + 1, + 1, + 'origin/release/26.4', + '' + ); + + expect(getAllProjects).toHaveBeenCalledWith(false, null, 'build'); + expect(projects).toBe('app-a,lib-b,lib-c'); +}); + +test('a non-empty ref lists the affected projects against base', () => { + const getAllProjects = jest + .spyOn(Utils, 'getAllProjects') + .mockReturnValue(['lib-b']); + + const projects = getAffectedProjects( + 'build', + 1, + 1, + 'origin/release/26.4', + 'feature/x' + ); + + expect(getAllProjects).toHaveBeenCalledWith( + true, + 'origin/release/26.4', + 'build' + ); + expect(projects).toBe('lib-b'); +}); + +test('normalizeRef turns an undefined or empty ref into an empty string', () => { + expect(normalizeRef(undefined)).toBe(''); + expect(normalizeRef('')).toBe(''); + expect(normalizeRef('feature/x')).toBe('feature/x'); +}); + +test('projects are distributed over the jobs in sorted order', () => { + jest + .spyOn(Utils, 'getAllProjects') + .mockReturnValue(['lib-e', 'lib-c', 'lib-a', 'lib-d', 'lib-b']); + + expect(getAffectedProjects('build', 1, 2, 'origin/master', '')).toBe( + 'lib-a,lib-c,lib-e' + ); + expect(getAffectedProjects('build', 2, 2, 'origin/master', '')).toBe( + 'lib-b,lib-d' + ); +}); diff --git a/tools/scripts/run-many/affected-projects.ts b/tools/scripts/run-many/affected-projects.ts index 5464b298..ac5f3a46 100644 --- a/tools/scripts/run-many/affected-projects.ts +++ b/tools/scripts/run-many/affected-projects.ts @@ -1,5 +1,9 @@ import { Utils } from '../artifacts/utils'; +export function normalizeRef(ref: string | undefined): string { + return ref ?? ''; +} + export function distributeProjectsEvenly( allProjects: string[], jobCount: number diff --git a/tools/scripts/run-many/run-many.ts b/tools/scripts/run-many/run-many.ts index 0a853330..3a34dc50 100644 --- a/tools/scripts/run-many/run-many.ts +++ b/tools/scripts/run-many/run-many.ts @@ -1,4 +1,4 @@ -import { getAffectedProjects } from './affected-projects'; +import { getAffectedProjects, normalizeRef } from './affected-projects'; import { execSync } from 'child_process'; import * as core from '@actions/core'; @@ -60,7 +60,7 @@ function main() { core.info(`Using default branch ${defaultBranch} as base`); base = `origin/${defaultBranch}`; } - const ref = process.argv[6]; + const ref = normalizeRef(process.argv[6]); core.info( `Inputs:\n target ${target},\n jobIndex: ${jobIndex},\n jobCount ${jobCount},\n base ${base},\n ref ${ref}` From 69021ec6a062b63e0b36e2c3ac6a250aba00038d Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Fri, 23 Oct 2026 20:39:12 +0200 Subject: [PATCH 03/11] PFM-ISSUE-34283 - Address review: publish before tag, migration guard, 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) --- .github/actions/artifacts/action.yml | 5 +- .github/workflow-templates/fe/fe-main.yml | 5 ++ .github/workflow-templates/fe/fe-pr.yml | 5 ++ .github/workflow-templates/fe/fe-release.yml | 5 +- .github/workflows/fe-release-flow.yml | 61 ++++++++++++-- .github/workflows/fe-release.yml | 3 + .github/workflows/fe-tag.yml | 3 + README.md | 74 +++++++++++------ .../design.md | 29 ++++--- .../plan.md | 35 +++++++- .../artifacts-handler-release-flow.test.ts | 82 +++++++++++++++---- .../artifacts/artifacts-handler-tag.test.ts | 2 + tools/scripts/artifacts/artifacts-handler.ts | 29 +++---- tools/scripts/artifacts/utils.test.ts | 11 ++- tools/scripts/artifacts/utils.ts | 15 +++- 15 files changed, 278 insertions(+), 86 deletions(-) diff --git a/.github/actions/artifacts/action.yml b/.github/actions/artifacts/action.yml index 88790108..ad939f76 100644 --- a/.github/actions/artifacts/action.yml +++ b/.github/actions/artifacts/action.yml @@ -1,11 +1,8 @@ name: 'Frontend Artifacts Handler Action' description: 'Github Action to handle frontend artifacts' outputs: - released: - description: 'true if a version was tagged and published (release flow only)' - value: ${{ steps.handler.outputs.released }} tag: - description: 'The pushed version tag (release flow only)' + description: 'The pushed version tag, set only once the version is published and tagged (release flow only)' value: ${{ steps.handler.outputs.tag }} previous_tag: description: 'The previous tag of the same release line, empty for X.Y.1 (release flow only)' diff --git a/.github/workflow-templates/fe/fe-main.yml b/.github/workflow-templates/fe/fe-main.yml index a36e9918..a804844f 100644 --- a/.github/workflow-templates/fe/fe-main.yml +++ b/.github/workflow-templates/fe/fe-main.yml @@ -11,6 +11,11 @@ permissions: actions: read checks: write +# a new push to the branch cancels the run of the previous one +concurrency: + group: fe-main-${{ github.ref }} + cancel-in-progress: true + env: NX_BRANCH: ${{ github.event.number }} NX_RUN_GROUP: ${{ github.run_id }} diff --git a/.github/workflow-templates/fe/fe-pr.yml b/.github/workflow-templates/fe/fe-pr.yml index f4e5e3ca..d77f0e67 100644 --- a/.github/workflow-templates/fe/fe-pr.yml +++ b/.github/workflow-templates/fe/fe-pr.yml @@ -10,6 +10,11 @@ permissions: actions: read checks: write +# a new push to the PR cancels the run of the previous one +concurrency: + group: fe-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: NX_BRANCH: ${{ github.event.number }} NX_RUN_GROUP: ${{ github.run_id }} diff --git a/.github/workflow-templates/fe/fe-release.yml b/.github/workflow-templates/fe/fe-release.yml index d0f131b6..a8b1a6ef 100644 --- a/.github/workflow-templates/fe/fe-release.yml +++ b/.github/workflow-templates/fe/fe-release.yml @@ -1,6 +1,8 @@ # Replaces the old two-file release (this file as tagger + fe-tag-pushed.yml / on_tag_pushed.yaml / tag_pushed.yml). # Delete BOTH old caller files in the same commit that adds this one: the release flow still pushes -# the tag with the PAT, so a tag-push workflow left in the tree would publish a second time. +# the tag with the PAT, so a tag-push workflow left in the tree would publish that tag again (and +# release manually pushed tags). The release flow's guard job fails while an old caller exists. +# PFM-ISSUE-34282 (pushing the tag with GITHUB_TOKEN) removes the tag-push trigger for good. name: 'Frontend Release CI' on: push: @@ -16,6 +18,7 @@ on: permissions: contents: write +# one release run per branch at a time; a running release is never cancelled concurrency: group: fe-release-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/fe-release-flow.yml b/.github/workflows/fe-release-flow.yml index 230b5071..faed3150 100644 --- a/.github/workflows/fe-release-flow.yml +++ b/.github/workflows/fe-release-flow.yml @@ -37,7 +37,8 @@ env: jobs: guard: runs-on: ubuntu-latest - permissions: {} + permissions: + contents: read steps: - name: Check dispatch ref env: @@ -50,6 +51,20 @@ jobs: exit 1 fi + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + sparse-checkout: .github/workflows + persist-credentials: false + + # 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) + if [ -n "$old" ]; then + echo "::error::Old release callers found: $(echo $old). Delete them in the same commit that adds the fe-release-flow caller (see 'FE Release Flow' in the github-actions README)." + exit 1 + fi + install-deps: needs: guard permissions: @@ -93,7 +108,6 @@ jobs: permissions: contents: read outputs: - released: ${{ steps.release.outputs.released }} tag: ${{ steps.release.outputs.tag }} previous_tag: ${{ steps.release.outputs.previous_tag }} is_latest: ${{ steps.release.outputs.is_latest }} @@ -122,14 +136,17 @@ jobs: - name: Install Modules if: steps.npm-cache.outputs.cache-hit != 'true' - run: npm ci && echo "modules were installed via npm" || yarn + run: npm ci - name: Configure git user + env: + GIT_USER: ${{ secrets.GIT_USER }} + GIT_USER_EMAIL: ${{ secrets.GIT_USER_EMAIL }} run: | - git config user.name ${{ secrets.GIT_USER }} - git config user.email ${{ secrets.GIT_USER_EMAIL }} + git config user.name "$GIT_USER" + git config user.email "$GIT_USER_EMAIL" - - name: Tag and Push to Jfrog NPM Registry + - name: Publish to Jfrog NPM Registry and Tag id: release uses: collaborationFactory/github-actions/.github/actions/artifacts@master env: @@ -139,9 +156,39 @@ jobs: RELEASE_FLOW: true FORCE: ${{ inputs.FORCE }} + # the release step writes the version before publishing; once the tag is pushed + # the release is complete and must not be unpublished + - name: Check for an incomplete release + id: incomplete + if: failure() || cancelled() + run: | + file="$RUNNER_TEMP/fe-release-version" + if [ ! -f "$file" ]; then + echo "Publishing did not start, nothing to clean up" + exit 0 + fi + tag=$(cat "$file") + if git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null; then + echo "$tag is already pushed, the release is complete: nothing to clean up" + exit 0 + fi + echo "$tag was not pushed: unpublishing its artifacts" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Unpublish the incomplete release + if: (failure() || cancelled()) && steps.incomplete.outputs.tag != '' + uses: collaborationFactory/github-actions/.github/actions/artifacts@master + env: + JFROG_BASE64_TOKEN: ${{ secrets.JFROG_BASE64_TOKEN }} + JFROG_URL: ${{ secrets.JFROG_URL }} + JFROG_USER: ${{ secrets.JFROG_USER }} + ONLY_DELETE_ARTIFACTS: true + TAG: ${{ steps.incomplete.outputs.tag }} + github-release: needs: release - if: needs.release.outputs.released == 'true' + # the tag is set only once the version is published and tagged + if: needs.release.outputs.tag != '' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/fe-release.yml b/.github/workflows/fe-release.yml index 3d6e6aaf..7e3deae6 100644 --- a/.github/workflows/fe-release.yml +++ b/.github/workflows/fe-release.yml @@ -20,6 +20,9 @@ jobs: release-version: runs-on: ubuntu-latest steps: + - name: Deprecation notice + run: echo "::warning::fe-release.yml (tag push) is deprecated. Migrate to fe-release-flow.yml, see 'FE Release Flow' in the github-actions README." + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: '' diff --git a/.github/workflows/fe-tag.yml b/.github/workflows/fe-tag.yml index 76f07fac..9322d114 100644 --- a/.github/workflows/fe-tag.yml +++ b/.github/workflows/fe-tag.yml @@ -27,6 +27,9 @@ jobs: tag: runs-on: ubuntu-latest steps: + - name: Deprecation notice + run: echo "::warning::fe-tag.yml is deprecated. Migrate to fe-release-flow.yml, see 'FE Release Flow' in the github-actions README." + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.GHA_REF }} diff --git a/README.md b/README.md index 112eaa8b..e5b4052f 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,36 @@ JavaScript action runtime since June 2026). `.github/workflows/fe-release-flow.yml` releases a FE repo in **one run** per push to `release/*` (or per manual dispatch): -guard → install-deps → build ∥ e2e → release (tag + publish, one job) → GitHub release +guard → install-deps → build ∥ e2e → release (publish + tag, one job) → GitHub release The `release` job computes the next `version/X.Y.Z` and checks whether any project is affected since -the previous tag of the release line. If so, it pushes the tag, then publishes all publishable -projects at that version. Tag before publish: if the tag push fails, the next run retries the same -version; if the publish fails after the tag was pushed, the version is lost and the next release is -the next patch (see Recovery). - -### Caller - -Copy `.github/workflow-templates/fe/fe-release.yml` to `.github/workflows/fe-release.yml` of the -consumer repo, with the `@release/X.Y` (or `@master`) ref of that branch. - -- **When migrating a branch, delete the old branch-push and tag-push caller files in the same - commit** (`fe-release.yml` as tagger plus `fe-tag-pushed.yml` / `on_tag_pushed.yaml` / - `tag_pushed.yml`). The release flow still pushes the tag with the PAT, so a tag-push workflow left - in the tree would publish a second time. -- Keep the file name `fe-release.yml`. The copy on the default branch is the seed for branch-off. -- Set `RUN_E2E: false` only for repos that do not run e2e before a release. +the previous tag of the release line. If so, it publishes all publishable projects at that version, +then pushes the tag. Publish before tag: a `version/*` tag always stands for a complete release. If +the publish or the tag push fails, or the run is cancelled before the tag is pushed, the job +unpublishes that version's artifacts again, so a re-run or the next push releases the same version. + +### Migration steps + +Migrate a branch of a consumer repo in **one commit**: + +1. **Add the release caller.** Copy `.github/workflow-templates/fe/fe-release.yml` to + `.github/workflows/fe-release.yml`, with the `@release/X.Y` (or `@master`) ref of that branch. + - Keep the file name `fe-release.yml`. The copy on the default branch is the seed for branch-off. + - Set `RUN_E2E: false` only for repos that do not run e2e before a release. +2. **Delete the old release callers**: `fe-release.yml` as tagger, plus `fe-tag-pushed.yml` / + `on_tag_pushed.yaml` / `tag_pushed.yml`. + - The release flow still pushes the tag with the PAT, so a tag-push workflow left in the tree + would try to publish that tag again, and would release manually pushed tags. + - The `guard` job fails while any workflow of the branch still calls `workflows/fe-release.yml` or + `workflows/fe-tag.yml`. + - PFM-ISSUE-34282 (pushing the tag with `GITHUB_TOKEN`, which triggers no workflows) removes the + tag-push trigger for good. +3. **Add the `concurrency` block to the PR and main callers**, as in the `fe-pr.yml` and + `fe-main.yml` templates. `fe-install-deps.yml` no longer cancels superseded runs; each caller + does that itself now. + +`fe-tag.yml` and `workflows/fe-release.yml` are **deprecated**: every run of them shows a warning. +They stay functional for branches that are not migrated yet. ### Triggers and `force` @@ -63,12 +74,16 @@ Passed with `secrets: inherit`: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, ### Recovery -- **Publish failed after the tag was pushed** (the job summary names the phase that failed): run a - **forced dispatch** on the branch. It releases the next patch; the failed version stays behind as - a dangling tag. "Re-run failed jobs" of a push run does not help: nothing is affected since the - tag that run already pushed, so it releases nothing. -- **Re-running only `github-release`** reuses the same tag and does nothing if the release already - exists. +- **Publish or tag push failed**: the "Unpublish the incomplete release" step has removed the + version's artifacts. "Re-run failed jobs" (or the next push) publishes and tags the same version. +- **The cleanup did not run or failed** (lost runner, JFrog error): the next publish of that version + fails with E403. Unpublish the packages of that version listed in the job log + (`npm unpublish /@X.Y.Z --force`), then re-run. +- **Only `github-release` failed**: use **"Re-run failed jobs"**. It reuses the tag of the `release` + job and creates the GitHub release (it does nothing if the release already exists). Do not use + "Re-run all jobs": the `release` job finds nothing affected since the tag it already pushed, so the + GitHub release is skipped. If the run can no longer be re-run, create the release by hand: + `gh release create version/X.Y.Z --verify-tag --generate-notes`. - **A forced dispatch** releases even when no project is affected. - **Warning: do not re-run a successful forced dispatch.** A re-run keeps `force: true`, so it cuts another patch with identical content. Re-running a successful push run is harmless: nothing is @@ -76,9 +91,16 @@ Passed with `secrets: inherit`: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, ### Concurrency -One run per branch at a time, non-cancelling (`fe-release-${{ github.ref }}`). GitHub keeps only the -newest pending run. No change is lost when a pending run is cancelled: the run that executes checks -everything since the previous tag of the line. +Concurrency is set by the callers, not by the reusable workflows: + +| Caller | Group | Behavior | +| ---------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| `fe-release.yml` | `fe-release-${{ github.ref }}` | one run per branch, never cancelled; only the newest pending run is kept | +| `fe-pr.yml` | `fe-pr-${{ github.event.pull_request.number }}` | a new push to the PR cancels the previous run | +| `fe-main.yml` | `fe-main-${{ github.ref }}` | a new push cancels the previous run | + +No release is lost when a pending release run is cancelled: the run that executes checks everything +since the previous tag of the line. ### GitHub release diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md index 0ec1324f..0898b8b0 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/design.md @@ -9,9 +9,16 @@ status: draft # Consolidate the FE 2.0 Release Flow into a Single Workflow - Design Approach +> **Revisions during implementation and PR #183 review** (user decisions; details in plan.md): +> - **Scope:** `master` only, with no upmerge to or backport onto `release/25.4` … `release/26.4`. +> - **Guard:** the `squad-fe` skip is removed. Every push runs the flow, as in the backend repos. +> - **Affected base:** the non-forced check uses the previous tag of the line, not `github.event.before`. Queued pushes whose runs were cancelled are not lost. +> - **Order:** publish first, then the tag (it was tag first). If the publish or the tag push fails, or the run is cancelled before the tag is pushed, a cleanup step in the workflow unpublishes that version (`ONLY_DELETE_ARTIFACTS` + `TAG`). A re-run or the next push then releases the **same** version. Every `version/*` tag stands for a complete release. This reverses the research Q4 consequence "a re-run moves to the next version". +> - **Constructor-ordering bug fixed:** a `TAG` on a `release/*` branch keeps its own version. This is needed so the cleanup can target the exact version. + ## Overview -Replace today's two chained runs per FE repo (branch push → `fe-tag`, then tag push → `fe-release`) with one reusable workflow, `fe-release-flow.yml`. Each caller invokes it with a single `uses:`. Within one run it does build + test → push the version tag → publish to JFrog → create the GitHub release. It also adds a `workflow_dispatch` path with a `force` input as the supported alternative to tagging by hand. This ticket delivers the github-actions side (workflow, tool changes, template, docs) on `master` and `release/25.4` … `release/26.4`. Consumer migration starts with the pilot PFM-ISSUE-34287. +Replace today's two chained runs per FE repo (branch push → `fe-tag`, then tag push → `fe-release`) with one reusable workflow, `fe-release-flow.yml`. Each caller invokes it with a single `uses:`. Within one run it does build + test → publish to JFrog → push the version tag → create the GitHub release (revised; originally tag before publish). It also adds a `workflow_dispatch` path with a `force` input as the supported alternative to tagging by hand. This ticket delivers the github-actions side (workflow, tool changes, template, docs) on `master` only (revised; originally also `release/25.4` … `release/26.4`). Consumer migration starts with the pilot PFM-ISSUE-34287. ## Problem Statement @@ -24,8 +31,8 @@ The split exists only because a tag pushed with `GITHUB_TOKEN` doesn't trigger w ### Requirements - One workflow, triggered by `push: branches: ['release/*']` and by `workflow_dispatch` with a user-selectable `force` input. Manual tag creation does not trigger a release, as far as workflow files can enforce it (a tag ruleset is deferred). -- Order within the run: build + test → push version tag → publish to JFrog → create the GitHub release. -- Tag-before-publish semantics: a failed tag push retries the same version, and a failed publish moves the next run to the next version. +- Order within the run: build + test → publish to JFrog → push version tag → create the GitHub release (revised; originally tag before publish). +- Publish-before-tag semantics (revised): a failed publish or tag push is cleaned up (the version is unpublished), and the next run retries the same version. - The GitHub release is created only after a successful publish. - Retries are deadlock-free. @@ -33,7 +40,7 @@ The split exists only because a tag pushed with `GITHUB_TOKEN` doesn't trigger w - Works under the current auth: the tag is still pushed with the PAT (`GIT_USER_TOKEN`, actor `squad-fe`). Only the GitHub release uses `GITHUB_TOKEN`. - Because of the PAT push, a migrated branch's tree must not contain the old `on: push: tags` caller, or it publishes a second time. - The existing `fe-tag.yml` / `fe-release.yml` stay untouched for unmigrated branches and repos. -- Every github-actions change is made per release branch (`master`, `release/25.4` … `26.4`) along the upmerge chain, with per-branch refs. +- ~~Every github-actions change is made per release branch (`master`, `release/25.4` … `26.4`) along the upmerge chain, with per-branch refs.~~ Revised: this ticket lands on `master` only. The older release lines keep the old flow, and a backport would be a separate effort. - Never test on `release/*`; validation uses the prepared live test setup (2026-06-08 incident rule). ## Design Decisions Summary @@ -48,7 +55,7 @@ The split exists only because a tag pushed with `GITHUB_TOKEN` doesn't trigger w 3. **Tag and publish happen in one tool process (new combined release mode)**: the artifacts tool computes the version from remote tags, applies the "anything affected?" check (skipped by `force`), tags and pushes, then publishes all publishable projects at exactly that version. - It writes `released`, `tag`, `previous_tag` and `is_latest` to `$GITHUB_OUTPUT`. - The existing `ONLY_BUMP_VERSION` and TAG modes are unchanged. The constructor-ordering bug (the release-branch block resets the TAG version to `X.Y.0`) is avoided by design in the new mode. - - Version calculation, tag and publish sit in the same job, so "Re-run failed jobs" recomputes and moves on to the next patch. + - Version calculation, publish and tag sit in the same job. After the cleanup, "Re-run failed jobs" recomputes the **same** version (revised; originally the next patch). 4. **`workflow_dispatch` with `force` (default `false`), guarded to the default branch and `release/*`**: - A `guard` job fails a dispatch on any other ref and skips the run's later jobs for `squad-fe` pushes. - On dispatch, build, storybook and e2e cover **all** projects. This needs a `run-many` fix so its existing "all projects" branch can be reached. @@ -65,12 +72,12 @@ The split exists only because a tag pushed with `GITHUB_TOKEN` doesn't trigger w - removing the install-deps concurrency - the new caller template, replacing `fe-release.yml` and deleting `fe-tag-pushed.yml` - README docs for the flow, secrets and force - - These changes land on `master` and on `release/25.4` … `26.4`. + - These changes land on `master` only (revised; originally also `release/25.4` … `26.4`). - The consumer rollout follows the pilot (PFM-ISSUE-34287) and excludes cplace-bayer-prompt-fe and the three dormant repos. This means: -- A release is a single, ordered run. A failed publish leaves at most a dangling tag; it never leaves a GitHub release without artifacts. -- Recovery is simple: "Re-run failed jobs" on `release` publishes the next patch, a re-run of `github-release` alone reuses the same tag, and a forced dispatch releases even when nothing is affected. +- A release is a single, ordered run. A failed publish is unpublished again and leaves no tag (revised; originally a dangling tag). It never leaves a GitHub release without artifacts. +- Recovery is simple: "Re-run failed jobs" on `release` publishes the same version again (revised), a re-run of `github-release` alone reuses the same tag, and a forced dispatch releases even when nothing is affected. - An accidental "Run workflow" click releases nothing (default `force: false` is a dry run), and dispatch on feature branches or tags is rejected. - The EPIC-1963 follow-ups below become github-actions-only changes; callers don't change: - split JFrog secrets instead of `DOT_NPMRC` @@ -97,7 +104,7 @@ What we're NOT doing (out of scope): Success criteria: - On the live test setup, one push to a `release/*` test branch produces exactly one run that tags, publishes all packages at that version, and creates a GitHub release with correct notes range and "Latest" flag. No second (tag-triggered) run starts. -- A deliberately failed publish leaves a tag without a GitHub release, and "Re-run failed jobs" publishes and releases the next patch. +- A deliberately failed publish leaves no tag and no artifacts of that version (the cleanup unpublished them). "Re-run failed jobs" publishes and releases the same version (revised). - A non-forced dispatch tags and publishes nothing. A forced dispatch on a release branch with nothing affected releases. A dispatch on a feature branch fails in `guard`. - Unit tests cover the new release mode, including tag/push commands, force bypass, `$GITHUB_OUTPUT` values (`released`, `tag`, `previous_tag`, `is_latest`) and the `[branch-off]` precedence over force. @@ -243,7 +250,7 @@ It narrows permissions per job. - New reusable `fe-release-flow.yml`. - Consumer caller normalized to `fe-release.yml`: the migration commit deletes both old caller files and adds it. - The new caller template replaces `workflow-templates/fe/fe-release.yml`. `fe-tag-pushed.yml` is deleted from the templates. -- This ticket delivers github-actions only (all items in decision 7), on `master` and `release/25.4` … `26.4`. +- This ticket delivers github-actions only (all items in decision 7), on `master` only (revised; originally also `release/25.4` … `26.4`). - The consumer rollout follows the pilot PFM-ISSUE-34287 and excludes cplace-bayer-prompt-fe and the three dormant repos. The `RUN_E2E: false` repos (legacy-fe-assets, loomeo, dataciders) migrate with the input set. **Rationale:** @@ -287,7 +294,7 @@ A thin caller per FE repo branch. It owns the triggers, concurrency, permissions - **GitHub git remote:** tag push via the PAT persisted by `actions/checkout`. - **GitHub Releases API:** via the `gh` CLI with `GITHUB_TOKEN`. - **cf-devkit branch-off:** copies the default-branch caller into `release/X.Y` and rewrites `@master` → `@release/X.Y`. The `[branch-off]` guard in the tool still prevents re-releasing branch-off commits. -- **The github-actions upmerge chain:** each change lands on `release/25.4` … `26.4` and `master`, with per-branch refs. +- ~~**The github-actions upmerge chain:** each change lands on `release/25.4` … `26.4` and `master`, with per-branch refs.~~ No longer applicable (master only). New consequence: release lines `25.4`–`26.4` keep the deprecated two-run flow until the next branch-off from master. The pilot needs a line cut after this change. ## Known Risks diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md index 14729c40..6d8f9c27 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/plan.md @@ -667,6 +667,33 @@ Run `/review:branch-merge` (or the `code-review` skill) over `origin/master..HEA - Consequence for recovery: "Re-run failed jobs" after a failed publish no longer releases, because nothing is affected since the dangling tag. Recovery is now a **forced dispatch**. Re-running a successful push run is harmless. The README Recovery section and the tool's step summary are updated. 3. ~~(README) Warn that "Re-run all jobs" after a successful release cuts another patch.~~ **Resolved:** with the tag-based base, this applies only to re-running a successful **forced dispatch** (`force: true` is kept). The README warns about exactly that. +**PR #183 review (Benno42, 11 inline comments): decisions** +- **#1** (`artifacts-handler.ts`, a re-run after a failed publish ends green without publishing). Confirmed: with base = previous tag, the dangling tag became the base. **Decision:** publish before tag, plus a workflow cleanup. In detail: + - The tool writes the version to `$RUNNER_TEMP/fe-release-version` before publishing, publishes, then tags. The tag outputs and `released=true` are written only after the tag push. + - The `release` job has two new steps, both under `failure() || cancelled()`. "Check for an incomplete release" skips the cleanup if the tag is already on the remote. "Unpublish the incomplete release" runs the artifacts action with `ONLY_DELETE_ARTIFACTS` + `TAG`. + - The constructor-ordering bug is fixed (`else if`): a `TAG` on a `release/*` branch keeps its version. + - Tests: publish-before-tag order, a failed publish (no tag, version file written), a failed tag push after publish, cleanup targeting the tag's version on a release branch, and in the tag test the version kept by TAG mode. + - README and design.md are updated. + - Rejected alternatives: "last tag with a GitHub release" as base (releases can be deleted without consequence), and skip-if-exists (a partial, abandoned publish would silently ship an old package under a new tag). + - To validate live: JFrog delete rights for release versions, and the `release-X.Y` dist-tag after an unpublish. +- **#2** (template, an old tag-push caller left behind and manual tags). **Decision:** all three suggestions. + - `guard` sparse-checks out `.github/workflows` (`contents: read`) and fails if any workflow still references `github-actions/.github/workflows/(fe-release|fe-tag).yml@`. Checked against samples: new caller, old tag-push caller, old tagger, empty directory. + - A `::warning::` deprecation step is added to `fe-tag.yml` and `workflows/fe-release.yml`. This revises "old reusables unchanged": the step is added, and their behavior is unchanged. + - PFM-ISSUE-34282 is linked in the README and the template comment. +- **#3** (`fe-install-deps.yml`, old `fe-pr`/`fe-main` runs no longer cancelled). **Decision:** `fe-install-deps.yml` keeps no concurrency. The callers own it: + - The `fe-pr.yml` template gets `fe-pr-${{ github.event.pull_request.number }}` (cancelling). + - The `fe-main.yml` template gets `fe-main-${{ github.ref }}` (cancelling; a superseded snapshot publish may be cut short, which is harmless because snapshot versions are unique). + - `fe-release.yml` keeps its non-cancelling group. + - The README turns "Caller" into numbered **Migration steps**. Step 3 adds the concurrency block to the PR and main callers. The Concurrency section has a per-caller table. +- **#4** (`run-many.ts`, `fe-main` pushes build all projects). **Decision:** intended (Phase 1 decision), no code change. The reply explains that the empty ref used to arrive as `undefined`, so the "all projects" branch was dead code. +- **#5** (`fe-release-flow.yml`, the `|| yarn` fallback). **Decision:** accepted. The release job uses plain `npm ci`. The deprecated `workflows/fe-release.yml` is left as it is. +- **#6** (`fe-release-flow.yml`, secrets inside `run:`). **Decision:** accepted. `GIT_USER` / `GIT_USER_EMAIL` are passed via `env:` and quoted. The deprecated `fe-tag.yml` is left as it is. +- **#7** (a test for "tag pushed, then publish fails"). **Decision:** no new test. That scenario is gone with publish → tag. The remaining cases are covered by the release-flow tests "a failed publish …" and "a failed tag push …". The workflow cleanup steps are covered by the live validation (manual testing step 3, revised). The reply says so. +- **#8** (README, only `github-release` failed). **Decision:** accepted. Recovery says to use "Re-run failed jobs", warns against "Re-run all jobs", and gives the manual `gh release create` fallback. +- **#9** (`released` is redundant with `tag`). **Decision:** accepted. The `released` output is removed from the tool, the action and the job, and `github-release` runs on `needs.release.outputs.tag != ''`. With publish → tag, `tag` is written only after publish and tag push both succeed. The tests assert "no output at all" for every no-release and failure case. +- **#10** (`calculateNewVersion` mutates the tag list). **Decision:** accepted. It returns a new `Version`, and the order comment in `tagAndPublishRelease()` is removed. `utils.test.ts` asserts that the list passed in is unchanged. +- **#11** (design.md promises backports). **Decision:** accepted. design.md is aligned to master only: the Overview order, Constraints, decision 7, Scope and the upmerge-chain risk are marked as revised. + #### 2. PR [OUTWARD] Push the branch and open the PR only after explicit user approval. - Base: `master`. Title: `PFM-ISSUE-34283 - Consolidate the FE 2.0 release flow into a single workflow`. @@ -680,9 +707,9 @@ Push the branch and open the PR only after explicit user approval. ### Success Criteria: #### Automated Verification: -- [ ] The PR targets `master`: `gh pr view --json baseRefName --jq .baseRefName` = `master` -- [ ] The PR's required checks (`pr-checks.yml`) pass: `gh pr checks` -- [ ] The full jest suite fails only in the 5 Windows baseline tests +- [x] The PR targets `master`: `gh pr view --json baseRefName --jq .baseRefName` = `master` (PR #183) +- [x] The PR's required checks (`pr-checks.yml`) pass: `gh pr checks` +- [x] The full jest suite fails only in the 5 Windows baseline tests - [ ] **HUMAN CHECKPOINT**: Call `AskUserQuestion` now with the question: "Phase 5 complete. Summary: self-review done (findings fixed or listed), PR # open against master. Please review the PR, and reply 'yes' once it is merged to close out the plan." Do NOT proceed until the user explicitly approves. This checkpoint cannot be skipped or pre-checked. #### Manual Verification: @@ -764,7 +791,7 @@ Then commit the merge with git's default message (`Merge remote-tracking branch - it tags `version/99.1.1` and publishes all packages at `99.1.1` (dist-tag `release-99.1`) - the GitHub release has the right notes range and "Latest" flag - no tag-triggered run starts -3. Make the publish fail on purpose → a tag without a GitHub release. "Re-run failed jobs" publishes and releases `99.1.2`. +3. Make the publish fail on purpose (revised after PR review #1) → "Unpublish the incomplete release" runs; there is no tag, no GitHub release and no artifacts of that version. "Re-run failed jobs" publishes, tags and releases the **same** version. Also check that the JFrog user may unpublish release versions, and where the `release-99.1` dist-tag points after the unpublish. Cancel a run during publish: the cleanup runs too. 4. A dispatch without force → build and test only. A dispatch with force and nothing affected → releases. A dispatch on a feature branch → fails in `guard`. 5. Clean up: the test tags, GitHub releases and JFrog versions, and the test branches. diff --git a/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts index 9cf545e8..72607e7a 100644 --- a/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts +++ b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts @@ -25,10 +25,11 @@ afterEach(() => { process.env.BASE = ''; process.env.TAG = ''; process.env.ONLY_BUMP_VERSION = 'false'; + process.env.ONLY_DELETE_ARTIFACTS = 'false'; process.env.PR_NUMBER = ''; }); -test('a push to a release branch with affected projects tags, then publishes all projects at that version', async () => { +test('a push to a release branch with affected projects publishes all projects at the new version, then tags', async () => { process.env.RELEASE_FLOW = 'true'; const execMock = mockGit(); mockBranch('release/26.4'); @@ -36,6 +37,8 @@ test('a push to a release branch with affected projects tags, then publishes all .spyOn(Utils, 'getAffectedNxProjects') .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const publish = mockPublish(); + const versionFile = Utils.writeReleaseVersionFile as jest.Mock; const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); await new ArtifactsHandler().handle(); @@ -55,13 +58,17 @@ test('a push to a release branch with affected projects tags, then publishes all expect.objectContaining({ major: 26, minor: 4, patch: 3 }), '@cplace-frontend-applications' ); - expect(pushCallOrder(execMock)).toBeLessThan( - allProjects.mock.invocationCallOrder[0] + // the cleanup step learns the version before anything is published + expect(versionFile).toHaveBeenCalledWith('version/26.4.3'); + expect(versionFile.mock.invocationCallOrder[0]).toBeLessThan( + publish.mock.invocationCallOrder[0] + ); + expect(publish.mock.invocationCallOrder[0]).toBeLessThan( + tagCallOrder(execMock) ); expect(output).toHaveBeenCalledWith('tag', 'version/26.4.3'); expect(output).toHaveBeenCalledWith('previous_tag', 'version/26.4.2'); expect(output).toHaveBeenCalledWith('is_latest', 'true'); - expect(output).toHaveBeenLastCalledWith('released', 'true'); }); test('a push to a release branch with nothing affected neither tags nor publishes', async () => { @@ -76,7 +83,7 @@ test('a push to a release branch with nothing affected neither tags nor publishe expect(gitTagOrPushCalls(execMock)).toEqual([]); expect(allProjects).not.toHaveBeenCalled(); - expect(output.mock.calls).toEqual([['released', 'false']]); + expect(output).not.toHaveBeenCalled(); }); test('a forced run on a release branch tags and publishes without running nx affected', async () => { @@ -98,7 +105,7 @@ test('a forced run on a release branch tags and publishes without running nx aff 'git push origin version/26.4.3', ]); expect(allProjects).toHaveBeenCalledTimes(1); - expect(output).toHaveBeenLastCalledWith('released', 'true'); + expect(output).toHaveBeenCalledWith('tag', 'version/26.4.3'); }); test('the first push of a new release line tags X.Y.1 even if nothing is affected', async () => { @@ -125,7 +132,7 @@ test('the first push of a new release line tags X.Y.1 even if nothing is affecte ); expect(output).toHaveBeenCalledWith('previous_tag', ''); expect(output).toHaveBeenCalledWith('is_latest', 'true'); - expect(output).toHaveBeenLastCalledWith('released', 'true'); + expect(output).toHaveBeenCalledWith('tag', 'version/26.5.1'); }); test('a release on an older line is not the latest and refers to its own previous tag', async () => { @@ -146,7 +153,27 @@ test('a release on an older line is not the latest and refers to its own previou expect(output).toHaveBeenCalledWith('is_latest', 'false'); }); -test('a failed tag push fails the run before anything is published', async () => { +test('a failed publish fails the run without tagging, leaving the version for the cleanup step', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + mockPublish().mockRejectedValue(new Error('publish failed')); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await expect(new ArtifactsHandler().handle()).rejects.toThrow( + 'publish failed' + ); + + expect(Utils.writeReleaseVersionFile).toHaveBeenCalledWith('version/26.4.3'); + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(output).not.toHaveBeenCalled(); +}); + +test('a failed tag push fails the run after publishing, without tag outputs', async () => { process.env.RELEASE_FLOW = 'true'; jest .spyOn(child_process, 'execSync') @@ -160,15 +187,16 @@ test('a failed tag push fails the run before anything is published', async () => jest .spyOn(Utils, 'getAffectedNxProjects') .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); - const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const publish = mockPublish(); const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); await expect(new ArtifactsHandler().handle()).rejects.toThrow( 'push rejected' ); - expect(allProjects).not.toHaveBeenCalled(); - expect(output).not.toHaveBeenCalledWith('released', 'true'); + expect(publish).toHaveBeenCalledTimes(1); + expect(output).not.toHaveBeenCalled(); }); test('a forced run on the default branch publishes a snapshot of all projects without a tag', async () => { @@ -186,7 +214,7 @@ test('a forced run on the default branch publishes a snapshot of all projects wi const [task, version] = allProjects.mock.calls[0]; expect(task).toBe(TASK.MAIN_SNAPSHOT); expect(version.toString()).toContain(`-${ArtifactsHandler.SNAPSHOT}-`); - expect(output.mock.calls).toEqual([['released', 'false']]); + expect(output).not.toHaveBeenCalled(); }); test('a run on the default branch without force does nothing', async () => { @@ -202,7 +230,7 @@ test('a run on the default branch without force does nothing', async () => { expect(gitTagOrPushCalls(execMock)).toEqual([]); expect(affected).not.toHaveBeenCalled(); expect(allProjects).not.toHaveBeenCalled(); - expect(output.mock.calls).toEqual([['released', 'false']]); + expect(output).not.toHaveBeenCalled(); }); test('the old version bump mode writes no outputs', async () => { @@ -234,6 +262,29 @@ test('a [branch-off] tip commit wins over a forced release flow', async () => { expect(allProjects).not.toHaveBeenCalled(); }); +test('the cleanup deletes the artifacts of the given version tag on a release branch', async () => { + process.env.ONLY_DELETE_ARTIFACTS = 'true'; + process.env.TAG = 'version/26.4.3'; + mockGit(); + mockBranch('release/26.4'); + const allProjects = jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + + await new ArtifactsHandler().handle(); + + // the tag's version, not the X.Y.0 of the release branch + expect(allProjects).toHaveBeenCalledWith( + TASK.RELEASE, + expect.objectContaining({ major: 26, minor: 4, patch: 3 }), + '@cplace-frontend-applications' + ); +}); + +function mockPublish() { + return jest + .spyOn(ArtifactsHandler.prototype as any, 'buildAndPublishProjects') + .mockResolvedValue(undefined); +} + function gitTagOrPushCalls(execMock: jest.SpyInstance) { return execMock.mock.calls .map(([command]) => String(command)) @@ -261,12 +312,13 @@ function mockBranch(branch: string) { jest.spyOn(Utils, 'getRootDir').mockReturnValue(rootDir); jest.spyOn(Utils, 'initGithubActionsFile').mockReturnValue(); jest.spyOn(Utils, 'appendStepSummary').mockReturnValue(); + jest.spyOn(Utils, 'writeReleaseVersionFile').mockReturnValue(); jest.spyOn(Utils, 'isBranchOffCommit').mockReturnValue(false); } -function pushCallOrder(execMock: jest.SpyInstance) { +function tagCallOrder(execMock: jest.SpyInstance) { const index = execMock.mock.calls.findIndex(([command]) => - String(command).startsWith('git push') + String(command).startsWith('git tag') ); return execMock.mock.invocationCallOrder[index]; } diff --git a/tools/scripts/artifacts/artifacts-handler-tag.test.ts b/tools/scripts/artifacts/artifacts-handler-tag.test.ts index 8d0fc214..cbf9f384 100644 --- a/tools/scripts/artifacts/artifacts-handler-tag.test.ts +++ b/tools/scripts/artifacts/artifacts-handler-tag.test.ts @@ -98,6 +98,8 @@ async function onMinorOrPatchTag() { await artifactHandler.handle(); expect(artifactHandler.onlyAffected).toBe(false); expect(getProjectsMock).toBeCalledTimes(1); + // the release branch must not reset the tag's version to X.Y.0 + expect(artifactHandler.currentVersion.getGitTag()).toBe(process.env.TAG); } function mockProjects() { diff --git a/tools/scripts/artifacts/artifacts-handler.ts b/tools/scripts/artifacts/artifacts-handler.ts index d67ba8fb..4eee32d7 100644 --- a/tools/scripts/artifacts/artifacts-handler.ts +++ b/tools/scripts/artifacts/artifacts-handler.ts @@ -74,8 +74,10 @@ export class ArtifactsHandler { this.currentVersion = new Version(this.tag.replace('version/', '')); this.task = TASK.RELEASE; } - // in case of a release Branch - if (this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX)) { + // in case of a release Branch (a given release Tag keeps its own version) + else if ( + this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX) + ) { this.currentVersion = Utils.getVersionFromReleaseBranch( this.currentBranch ); @@ -215,7 +217,6 @@ export class ArtifactsHandler { } private async tagAndPublishRelease() { - Utils.writeOutput('released', 'false'); if ( !this.currentBranch.startsWith(ArtifactsHandler.RELEASE_BRANCH_PREFIX) ) { @@ -238,7 +239,6 @@ export class ArtifactsHandler { } const allTags = Utils.getAllVersionTagsInAscendingOrder(); - // read before calculateNewVersion: it bumps the returned tag object in place const previous = Utils.getLatestTagForReleaseBranch( this.currentBranch, Utils.getVersionFromReleaseBranch(this.currentBranch), @@ -277,15 +277,9 @@ export class ArtifactsHandler { } const gitTag = this.calculatedNewVersion.getGitTag(); - console.log(`Tag phase: pushing ${gitTag}`); - console.log(execSync(`git tag ${gitTag}`).toString()); - console.log(execSync(`git push origin ${gitTag}`).toString()); - Utils.writeOutput('tag', gitTag); - Utils.writeOutput('previous_tag', previousTag); - Utils.writeOutput('is_latest', String(isLatest)); - Utils.appendStepSummary( - `Tag phase: pushed \`${gitTag}\`. Publish phase started; if this run failed after this line, the tag is dangling: run a forced dispatch on this branch to release the next patch.` - ); + // read by the workflow's cleanup step, which unpublishes this version if the run + // fails or is cancelled before the tag is pushed + Utils.writeReleaseVersionFile(gitTag); console.log( `Publish phase: publishing all publishable projects as ${this.calculatedNewVersion}` @@ -294,9 +288,16 @@ export class ArtifactsHandler { this.onlyAffected = false; this.initProjects(); await this.buildAndPublishProjects(); - Utils.writeOutput('released', 'true'); Utils.appendStepSummary( `Publish phase: published all publishable projects as \`${this.calculatedNewVersion}\`` ); + + console.log(`Tag phase: pushing ${gitTag}`); + console.log(execSync(`git tag ${gitTag}`).toString()); + console.log(execSync(`git push origin ${gitTag}`).toString()); + Utils.writeOutput('tag', gitTag); + Utils.writeOutput('previous_tag', previousTag); + Utils.writeOutput('is_latest', String(isLatest)); + Utils.appendStepSummary(`Tag phase: pushed \`${gitTag}\``); } } diff --git a/tools/scripts/artifacts/utils.test.ts b/tools/scripts/artifacts/utils.test.ts index da70f63c..45414bbb 100644 --- a/tools/scripts/artifacts/utils.test.ts +++ b/tools/scripts/artifacts/utils.test.ts @@ -256,13 +256,20 @@ test('can bump version from a precomputed tag list without listing the remote ta const execMock = jest .spyOn(child_process, 'execSync') .mockReturnValue(Buffer.from('')); - const version = Utils.calculateNewVersion('release/22.4', [ + const tags = [ new Version('22.3.7'), new Version('22.4.11'), new Version('22.4.12'), - ]); + ]; + const version = Utils.calculateNewVersion('release/22.4', tags); expect(version).toStrictEqual(new Version('22.4.13')); expect(execMock).not.toHaveBeenCalled(); + // the tag list passed in is not changed + expect(tags.map((tag) => tag.toString())).toEqual([ + '22.3.7', + '22.4.11', + '22.4.12', + ]); }); test('can create hashed timestamp correctly formatted', () => { diff --git a/tools/scripts/artifacts/utils.ts b/tools/scripts/artifacts/utils.ts index 4a4bdf79..05475476 100644 --- a/tools/scripts/artifacts/utils.ts +++ b/tools/scripts/artifacts/utils.ts @@ -16,6 +16,7 @@ export class Utils { 'No snapshots of projects have been published (probably no project is affected)'; // cf-devkit's branch-off marks every commit it creates with this literal prefix. public static readonly BRANCH_OFF_MARKER = '[branch-off]'; + public static readonly RELEASE_VERSION_FILE = 'fe-release-version'; public static globProjectJSON(): string[] { const projects = execSync('ls */**/project.json').toString().trim(); @@ -208,8 +209,9 @@ export class Utils { versionOfBranch.patch = 1; return versionOfBranch; } else { - latestExistingTag.patchVersion(VERSION_BUMP.PATCH); - return latestExistingTag; + const newVersion = new Version(latestExistingTag.toString(), ''); + newVersion.patchVersion(VERSION_BUMP.PATCH); + return newVersion; } } @@ -353,6 +355,15 @@ export class Utils { core.setOutput(name, value); } + public static writeReleaseVersionFile(gitTag: string) { + if (process.env.RUNNER_TEMP) { + fs.writeFileSync( + path.join(process.env.RUNNER_TEMP, Utils.RELEASE_VERSION_FILE), + gitTag + ); + } + } + public static appendStepSummary(markdown: string) { if (process.env.GITHUB_STEP_SUMMARY) { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`); From 8898350e04dd28b5ab7491860cabaf1949e94d9f Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Mon, 2 Nov 2026 13:12:07 +0100 Subject: [PATCH 04/11] PFM-ISSUE-34283 - Add live test document for clone-cplace-fe 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 --- .../clone-cplace-fe-live-tests.md | 668 ++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md new file mode 100644 index 00000000..27928272 --- /dev/null +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -0,0 +1,668 @@ +--- +date: 2026-11-02 +last_updated: 2026-11-02 +branch: improvement/PFM-ISSUE-34283-Consolidate-the-FE-2-0-release-flow-into-a-master (PR #183) +topic: "Live tests of the FE release flow (PFM-ISSUE-34283, PFM-ISSUE-34282) in collaborationFactory/clone-cplace-fe" +tags: [live-test, clone-cplace-fe, fe-release-flow, github-token, jfrog, cplace-npm-test, agent-executable] +status: draft +--- + +# Live tests of the FE release flow in `clone-cplace-fe` + +This document has three parts: + +1. **Part 1: the test environment.** How it is built, what it contains on 2026-11-02, and how to rebuild it for a later change. +2. **Part 2: the execution model** for an agent. It covers tools, state capture, triggering, evaluation and stops. +3. **Part 3: the test cases.** Each case has a trigger, the expected end state and the evidence to collect. + +**What is tested:** +- **Phase A:** the consolidated release flow from PR #183 (PFM-ISSUE-34283). The tag is still pushed with the PAT. +- **Phase B:** the same flow after PFM-ISSUE-34282, which pushes the tag with `GITHUB_TOKEN` and uses job-scoped permissions. + +Phase A doubles as the baseline that Phase B is compared against. This document replaces the "Manual Testing Steps" of this ticket's plan ([plan.md](./plan.md), lines 788-798) as the executable version. Phase B is the follow-up for PFM-ISSUE-34282. Its test items come from the 34282 research ([research.md](../2026-10-26_PFM-ISSUE-34282_github-token-scoped-permissions/research.md), open question 7). + +The method follows the backend precedent `cplace-gh-workflows/specs/2026-10-19_PFM-ISSUE-35048_oversized-build-workspace-cache/clone-cplace-staging-procedure.md`, adapted to the FE repo. + +**Placeholders:** + +| Name | Value | +|---|---| +| `R` | `collaborationFactory/clone-cplace-fe` | +| `GA` | `collaborationFactory/github-actions` | +| `UP` | `collaborationFactory/cplace-fe` (upstream) | +| `BR` | `release/26.4` (the release line under test) | +| `TEST_BRANCH` | the github-actions test branch. Phase A: `test/PFM-ISSUE-34283-release-flow`. Phase B: `test/PFM-ISSUE-34282-`. | +| `J` | `--server-id cplace.jfrog.io` | +| `OUT` | a results directory outside every repo (the agent's scratchpad) | +| `WT` | a local working copy of `R` (see 1.4) | +| `N` | the version a run is expected to release; `PREV` is the highest existing `version/26.4.*` tag | + +--- + +# Part 1: Test environment + +## 1.1 Components + +``` +cplace-fe (upstream, untouched) + │ fork on an older commit + ▼ +clone-cplace-fe ──caller fe-release.yml──▶ github-actions@TEST_BRANCH ──▶ JFrog cplace-npm-test + ▲ (release/26.4, main) (feature branch + refs to itself, (npm, @cplace-next/*) + │ test jobs removed) + └── upstream PRs replayed one by one (cherry-pick + push) to create real changes +``` + +| Component | Role | Write access in tests | +|---|---|---| +| `clone-cplace-fe` | Consumer repo, a fork of `cplace-fe` (internal) | Everything: branches including `release/*` and `main`, tags, releases, secrets | +| `github-actions` `TEST_BRANCH` | The reusable workflows under test | Only the test branch, and **every push stops for user confirmation** | +| JFrog `cplace-npm-test` | Target npm registry for publishes | Only our own versions, exact paths, never wildcards (see 4.2) | +| `cplace-fe`, the github-actions `master`/`release/*` branches, the real npm registries | Production | **None** | + +## 1.2 `clone-cplace-fe` facts (read 2026-11-02) + +| Item | Value | Source | +|---|---|---| +| Fork parent | `collaborationFactory/cplace-fe`, visibility internal, default branch `main` | `gh api repos/$R` | +| Branches | `main`, `release/26.4`, plus the leftover `fix/PFM-ISSUE-35119-…` from replay PR #1 | `gh api repos/$R/branches` | +| Fork point `release/26.4` | upstream `b93fdc38e` (PFM-ISSUE-35170, 2026-10-22) | commit list | +| Fork point `main` | upstream `320b61b22` (#3001, 2026-10-23) | commit list | +| Setup commits on `release/26.4` | `5a378a07e` test-fork identity, `58c416208` no CODEOWNERS, `803be74fb` new release caller, `dac8698d3` test jobs removed (HEAD) | 1.3 | +| Setup commits on `main` | `56cd3e364` identity, `a55a9efb5` no CODEOWNERS, `54ca47f60` test jobs removed. Replay PR #1 (`#2989`) merged as `0640d604d`. | commit list | +| Release caller on `release/26.4` | `.github/workflows/fe-release.yml` → `fe-release-flow.yml@test/PFM-ISSUE-34283-release-flow`, `permissions: contents: write`, `secrets: inherit`, concurrency `fe-release-${{ github.ref }}`, non-cancelling | file content | +| Old callers | Deleted on `release/26.4` (`push_to_release.yaml`, `on_tag_pushed.yaml`). **Still present on `main`**, so `main` is not migrated. | contents API | +| Version tags | `version/26.4.1` only. It is lightweight, on `58c416208`, and was released by the **old** flow: run `41081346891` "Frontend Tag Pushed CI", actor `squad-fe` (the PAT user). | refs, runs API | +| GitHub releases | None. The old flow created none, so `releases/latest` returns 404. | releases API | +| Registry state `26.4.1` | 28 `@cplace-next/*` tarballs, created 2026-10-30 15:39–15:44Z. Example dist-tags (`cf-frontend-sdk`): `release-26.4 → 26.4.1`, `snapshot`/`latest → 0.0.0-SNAPSHOT-mvv12a4h-20261030`. | AQL, npm metadata API | +| Secrets | `DOT_NPMRC`, `GIT_USER`, `GIT_USER_EMAIL`, `GIT_USER_TOKEN`, `GRADLE_PROPERTIES`, `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`. There is no `PERCY_TOKEN`, which is fine because e2e is removed. | `gh api repos/$R/actions/secrets` | +| Default workflow token | `default_workflow_permissions: read` | `gh api repos/$R/actions/permissions/workflow` | +| Rulesets and protection | None on `release/26.4` or `main`, `protected: false`, no repository properties | `rules/branches/*`, `properties/values` | +| Runs with the new flow | `41088060031` (`803be74fb`) and `41432167152` (`dac8698d3`). Both ended with `release` = success and "No projects are affected since version/26.4.1"; `github-release` was skipped. | jobs API, log | + +**What the registry target depends on.** +- The artifacts tool writes a dist `.npmrc` with `@cplace-next:registry=${JFROG_URL}`. It sets each dist `package.json` `publishConfig.registry` from the same source (`tools/scripts/artifacts/nx-project.ts:290-341`). +- **The `JFROG_URL` secret therefore decides the target.** The source `publishConfig` change in `5a378a07e` is a second safety net. +- The tool prints the dist `.npmrc` to the job log. The `_auth` value is masked. Preflight P5 uses that log line. + +**Release semantics the expectations rely on** (`tools/scripts/artifacts/artifacts-handler.ts:219-302`): +- On a `release/*` push, the tool computes `N` as the highest `version/.*` patch + 1. On a line with no tags it releases the first version of the line. +- **Affected check:** with no project affected since `PREV`, nothing is released. If any project is affected, the publish phase publishes **all publishable projects** at `N`, not only the affected ones. `FORCE` skips the affected check. +- **Order:** publish first, then `git tag` (a lightweight tag) and `git push origin `. Then the `github-release` job runs `gh release create --verify-tag --generate-notes --latest= [--notes-start-tag PREV]`. +- **npm dist-tag:** a release sets `release-`, from `Version.getNpmTag()`. `latest` and `snapshot` are not touched. +- A tip commit whose subject starts with `[branch-off]` is neither tagged nor published. +- **Guard job:** a dispatch outside the default branch or `release/*` fails. Any workflow file that references `github-actions/.github/workflows/(fe-release|fe-tag).yml@` also fails it. +- **Failure cleanup:** if the `release` job fails or is cancelled after the version file is written and before the tag exists on the remote, the "Unpublish the incomplete release" step deletes every project at `N`. + +## 1.3 How the environment was built (recipe for rebuilding) + +Steps 1–7 were done once. Repeat them to build a fresh environment, for example for another release line or after the fork is deleted. + +1. **Fork on an old commit.** + - Fork `UP` as `clone-cplace-fe` (internal). + - Reset its `release/` and `main` to upstream commits a few weeks back. + - The upstream commits after the fork point are the **replay queue** (1.5). They give real code changes that make projects affected, without inventing commits. + - Delete all other branches and tags. +2. **Test-fork identity** (commit `5a378a07e` on `release/26.4`, `56cd3e364` on `main`): + - Rename the root package to `@cplace-next/clone-cplace-fe`, plus the docs and the e2e Docker image and volume names. + - Point every `publishConfig.registry` to `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm-test/`. + - Make the scheduled or auto-triggered workflows manual-only: `cron_check_upmerge`, `cron_cleanup_snapshots`, `deploy_storybook`, `auto-pr-upmerge`. +3. **No CODEOWNERS** (`58c416208`), so upstream owners are not asked to review clone PRs. +4. **Secrets** (repository level): + - `JFROG_URL` = the `cplace-npm-test` npm API URL. + - `JFROG_USER` / `JFROG_BASE64_TOKEN` = a JFrog identity with deploy **and delete** rights on `cplace-npm-test` only, and no write access to the real npm repositories. + - `DOT_NPMRC` = a read `.npmrc` for installing dependencies through the cplace npm proxy. + - `GIT_USER` / `GIT_USER_EMAIL` / `GIT_USER_TOKEN` stay as long as Phase A runs: the 34283 flow declares the PAT as a required secret. +5. **github-actions test branch.** + - Create `TEST_BRANCH` from the feature branch under test. + - Commit 1 rewrites **every** internal `collaborationFactory/github-actions/...@master` ref to `@TEST_BRANCH`. In 34283 that is commit `976d511`, touching 14 workflow files. + - Commit 2 removes the test jobs. In 34283 that is `b8ca5b4`, which removes the `e2e` job and the `RUN_E2E` input, and makes `release` need only `build`. + - Push it (stop: user confirmation). + - The test branch must be **recreated** whenever the feature branch changes. Preflight P3 detects drift. +6. **Migrate the release caller on `release/`** (`803be74fb`): + - Add `.github/workflows/fe-release.yml` from `.github/workflow-templates/fe/fe-release.yml`, with the `uses:` ref set to `@TEST_BRANCH`. + - In the **same commit**, delete `push_to_release.yaml` and `on_tag_pushed.yaml`. + - Add the per-caller concurrency blocks to `pull_request.yaml` and `push_main.yaml`. +7. **Remove test jobs from the callers** (`dac8698d3` / `54ca47f60`). + - Drop the e2e and code-quality jobs from the PR and main callers. + - Delete the test-only workflows: devkit e2e, visual regression, baseline healing, Windows. +8. **Baseline release.** The line needs one released version so `PREV` exists. Here `version/26.4.1` came from the old flow (run `41081346891`) before step 6. +9. **Tag-push canary** (step S1 in 1.6, not yet done). A tag-triggered workflow that only logs who pushed the tag. Phases A and B differ here. + +**Branch protection and rulesets.** Today nothing protects `clone-cplace-fe`. If org rulesets ever start to apply (for example by a repository `type` property), pushes to `release/*` and the teardown can be declined. In that case follow the "Rulesets" section of the backend procedure: an admin sets the property to `generic` for the test window. + +## 1.4 Working copy and tools + +```bash +WT="$OUT/wt-clone-cplace-fe" +git clone --filter=blob:none --branch release/26.4 https://github.com/collaborationFactory/clone-cplace-fe.git "$WT" +git -C "$WT" remote add upstream https://github.com/collaborationFactory/cplace-fe.git +git -C "$WT" fetch --filter=blob:none upstream release/26.4 +``` + +| Tool | Note | +|---|---| +| `gh` | Version 2.29 locally. Use `gh api` rather than the newer `--json` subcommands, except `gh run view --json` and `gh run rerun`/`cancel`/`watch`, which exist. | +| `jf` | Version 2.53, server id `cplace.jfrog.io`. Pass `$J` on every call. | +| Git Bash on Windows | `export MSYS_NO_PATHCONV=1`, or `jf rt curl /api/...` and `git show :` get their paths rewritten (seen as `curl: (3) URL rejected`). | +| `jq` | 1.6 is available. | +| GitHub events API | **Not usable as evidence.** For `R` it returned only 10 events and no tag creation. The pusher of a tag is proven by the canary run's actor (S1). | + +## 1.5 Replay queue (upstream `release/26.4`, first-parent, after `b93fdc38e`) + +Replay in this order, one commit per push. Merge commits use `git cherry-pick -m 1`; squash commits use a plain cherry-pick. The "projects" column lists the top-level project directories each commit touches. It hints at what becomes affected; the tool's log is authoritative. + +| # | Upstream SHA | Kind | Subject | Projects touched | Used by | +|---|---|---|---|---|---| +| 1 | `2b5dc05cc61` | merge #2992 | 35089 advanced-search z-index (to 26.4) | cf-ai-companion-e2e, cf-e2e-testing-sdk, cf-frontend-sdk, cf-platform-lib | A2 | +| 2 | `9830623eecd` | merge | Upmerge release/26.3 into release/26.4 | cf-frontend-sdk(-e2e, -visual-e2e), cf-editor-lib, cf-platform-lib | A11 push 1 | +| 3 | `1df8cfc88e5` | commit | cf-editor-lib visual baselines after upmerge | cf-editor-lib-e2e | A11 push 2 | +| 4 | `a1f33f048dc` | merge | Upmerge release/26.3 into release/26.4 | (no project directory) | A11 push 3 | +| 5 | `e2d33835d4b` | squash #2969 | 35090 Rich Text Editor too small | cf-editor-lib | B1 | +| 6 | `4b83e9883e5` | merge #3010 | 35135 split ai-companion e2e (**adds apps**, so the project set changes) | cf-ai-companion-1/2-e2e, … | B2 (or spare) | +| 7 | `d9d83f5a6ee` | merge #3013 | 35238 re-enable tooltip tests | cf-frontend-sdk | spare | +| 8 | `94740beacd2` | squash #2998 | 35129 tooltip in Table 2.0 headers | cf-frontend-sdk, cf-e2e-testing-sdk, table-e2e | spare | +| 9 | `a0ee066eaf8` | merge #3018 | 34449 document handling | cf-ai-companion-1-e2e, cf-platform-lib, … | spare | +| 10 | `d900454c377` | merge #3024 | 35101 attachment permission ceiling | libs/api, cf-platform-lib, … | spare | + +Recompute the list before use: + +```bash +git -C "$WT" log --first-parent --reverse --format='%h %ad %s' --date=short b93fdc38e..upstream/release/26.4 +``` + +**Replay rules:** +- **Order and conflicts.** Keep the upstream order; skipping a commit can make later ones conflict. If a cherry-pick conflicts in a file changed by the fork setup (`package.json` `publishConfig`, `.github/workflows/*`), keep the **fork's** side and record it. Any other conflict is a stop. +- **Never replay `.github/workflows` changes from upstream** without checking that they don't bring back an old caller. The guard would fail, which is a test in itself (A6) but not wanted here. +- **Direct push vs. PR.** A direct push to `BR` is the production-equivalent event: a merged PR is a push to the branch. The PR route (as used for replay PR #1 on `main`) also works, but adds PR runs that these tests don't need. + +## 1.6 One-time additions for these tests + +**S1: tag-push canary on `BR`.** Commit and push `.github/workflows/zz-tag-push-canary.yml`: + +```yaml +name: 'Tag Push Canary' +on: + push: + tags: ['version/*'] +permissions: {} +jobs: + canary: + runs-on: ubuntu-latest + steps: + - run: echo "tag ${{ github.ref_name }} pushed by actor=${{ github.actor }} triggering=${{ github.triggering_actor }}" +``` + +- Tag pushes run the workflows found in the **tagged commit's** tree. That tree is `BR`, so the canary has to live there. `main`'s `on_tag_pushed.yaml` never fires for a tag on a `BR` commit. +- The canary contains no `fe-release.yml@` / `fe-tag.yml@` reference, so the guard ignores it. +- **Expected:** with a PAT push (Phase A), every new `version/*` tag starts a canary run with actor `squad-fe`. With `GITHUB_TOKEN` (Phase B), it starts **no** run. +- The push that adds the canary also starts a release run. Nothing is affected, so that run is an extra A1 observation. + +**S2 (Phase B only): switch the caller to the 34282 test branch.** This needs the 34282 implementation and its test branch, built per 1.3 step 5. +- Commit on `BR`: in `.github/workflows/fe-release.yml`, change `@test/PFM-ISSUE-34283-release-flow` to `@test/PFM-ISSUE-34282-`. +- Re-apply the current template's header comment if 34282 changed it. +- Change nothing else. Then rerun preflight. + +**S3 (optional, A13 only): migrate `main`.** Add the same caller to `main` and delete `push_to_release.yaml` + `on_tag_pushed.yaml` there. Otherwise the guard fails on every `main` dispatch, and the forced snapshot path cannot be tested. + +## 1.7 Known differences from production + +- **No e2e** in the release flow (`b8ca5b4`), so the `RUN_E2E` input and the e2e→release dependency are not exercised. They are covered statically by actionlint on the feature branch. +- **Size.** The clone is the size of `cplace-fe`, so build times are realistic. The 120-minute `fe-e2e` timeout risk (34283 design risk 3) cannot be measured here. +- **Rulesets.** There is no `version/*` tag ruleset and no branch protection. Behaviour under a tag ruleset (34283 Q10) is not covered. +- **Release lines.** There is a single line (`26.4`), so `is_latest` is always `true` unless A12 creates an older line. +- **Registry.** `cplace-npm-test` is shared with the backend staging (`@cplace-assets/*`). Our scope is `@cplace-next/*`. + +--- + +# Part 2: Execution model (for an agent) + +## 2.1 Rules and stops + +**The agent runs autonomously except at these stops (ask the user):** +- Every push to `GA` (any branch). The agent never pushes to `UP`, or to `GA`'s `master` or `release/*`. +- **Preflight.** Any preflight check fails. +- **Replay.** A cherry-pick conflict outside the fork-setup files. +- **Results.** + - An expected-state row fails. Record it, stop the phase, and do not "fix forward". + - Any registry write outside `cplace-npm-test` or outside `@cplace-next/*` shows up. +- **Phase B.** Before S2, and before deleting or changing a repository secret (B3). +- **Teardown.** Before every JFrog delete (4.2), show the hit list. + +**Everything else in `R` is allowed:** pushes to `BR` and throwaway branches, tags, releases, dispatches, re-runs and cancels. + +**Runs one at a time.** Except A11, start a case only when the previous case's runs, including canary runs, are completed and evaluated. All cases share the `fe-release-refs/heads/release/26.4` concurrency group, and the release state is cumulative. + +## 2.2 Helper library + +Write this to `$OUT/lib.sh` and source it in every shell: + +```bash +export MSYS_NO_PATHCONV=1 JFROG_CLI_AVOID_NEW_VERSION_WARNING=TRUE +R=collaborationFactory/clone-cplace-fe; J="--server-id cplace.jfrog.io"; LINE=26.4; BR=release/26.4 + +# snapshot : tags, releases, npm release versions and dist-tags of @cplace-next/* +snapshot() { + local d=$1; mkdir -p "$d" + gh api "repos/$R/git/matching-refs/tags/version/" --jq '.[] | "\(.ref) \(.object.type) \(.object.sha)"' | sort > "$d/tags.txt" + gh api "repos/$R/releases?per_page=100" \ + --jq '.[] | "\(.tag_name) draft=\(.draft) pre=\(.prerelease) author=\(.author.login)"' | sort > "$d/releases.txt" + (gh api "repos/$R/releases/latest" --jq .tag_name 2>/dev/null || echo none) > "$d/latest-release.txt" + jf rt curl $J -s -XPOST /api/search/aql -H 'Content-Type: text/plain' -d \ + 'items.find({"repo":"cplace-npm-test","path":{"$match":"@cplace-next/*/-/@cplace-next"},"name":{"$match":"*.tgz"}}).include("path","name","created")' \ + | jq -r '.results[] | "\(.path|split("/")[1])\t\(.name)\t\(.created)"' | grep -v -i snapshot | sort > "$d/npm-tgz.tsv" + cut -f1 "$d/npm-tgz.tsv" | sort -u > "$d/npm-packages.txt" + : > "$d/dist-tags.tsv" + while read -r p; do + jf rt curl $J -s "/api/npm/cplace-npm-test/-/package/@cplace-next%2F$p/dist-tags" \ + | jq -r --arg p "$p" 'to_entries[] | "\($p)\t\(.key)\t\(.value)"' >> "$d/dist-tags.tsv" + done < "$d/npm-packages.txt" + sort -o "$d/dist-tags.tsv" "$d/dist-tags.tsv" +} + +# runs_for : release-flow and canary runs for a commit +runs_for() { gh api "repos/$R/actions/runs?head_sha=$1&event=$2&per_page=20" --jq '.workflow_runs[] | "\(.id) \(.name) \(.status) \(.conclusion)"'; } + +# runs_since : every run created since (e.g. the canary on a tag) +runs_since() { gh api "repos/$R/actions/runs?created=>=$1&per_page=50" --jq '.workflow_runs[] | "\(.id)\t\(.name)\t\(.event)\t\(.head_branch)\t\(.actor.login)\t\(.status)/\(.conclusion)"'; } + +# capture : run metadata, jobs, full logs +capture() { + local run=$1 d=$2; mkdir -p "$d" + gh api "repos/$R/actions/runs/$run" > "$d/run.json" + gh api "repos/$R/actions/runs/$run/jobs?filter=all&per_page=100" > "$d/jobs.json" + jq -r '.jobs[] | "\(.name)\t\(.conclusion)\t\(.id)"' "$d/jobs.json" > "$d/jobs.tsv" + while IFS=$'\t' read -r name _ id; do + gh api "repos/$R/actions/jobs/$id/logs" > "$d/job-$id.log" 2>/dev/null || true + done < "$d/jobs.tsv" +} + +# wait_run : block until completed, print conclusion +wait_run() { until [ "$(gh api repos/$R/actions/runs/$1 --jq .status)" = completed ]; do sleep 30; done; gh api repos/$R/actions/runs/$1 --jq .conclusion; } +``` + +**Finding the run.** +- After a push: `runs_for "$SHA" push`. It can take 5–20 s to appear, so poll. +- After a dispatch: `runs_since "$T0"` filtered on event `workflow_dispatch` and the branch. +- After a re-run: the same run id with a higher `run_attempt`. Capture the previous attempt **before** re-running: `gh run view $RUN -R $R --attempt --log`. + +## 2.3 Per-case procedure + +1. `T0=$(date -u +%Y-%m-%dT%H:%M:%SZ)`, then `snapshot "$OUT//before"`. +2. Trigger as described in the case. Record the SHA, branch and run id(s) in `$OUT//case.env`. +3. `wait_run`, then `capture` into `$OUT//run--/`. +4. Wait for the canary or any tag-triggered runs to settle (poll `runs_since "$T0"` until nothing is `in_progress`/`queued` for 60 s). Then `snapshot "$OUT//after"`. +5. Registry metadata is recalculated **asynchronously**. If `dist-tags.tsv` lags the tarballs, re-snapshot up to 3 times, 2 minutes apart, before calling a dist-tag row failed. +6. Evaluate every row of the case against the rules in 3.1. Write `$OUT//verdict.md` with the comparison table (row, expected, actual, PASS/FAIL, evidence file). +7. Append what the case created (tags, releases, npm versions, branches) to `$OUT/ledger.tsv`. Teardown (Part 4) reads it. +8. Append one line to `$OUT/results.md`: `| | | PASS/FAIL | |`. + +## 2.4 Preflight (read-only, before Phase A and again after S2) + +| ID | Check | Command (sketch) | Pass | +|---|---|---|---| +| P1 | Caller ref | `gh api "repos/$R/contents/.github/workflows/fe-release.yml?ref=$BR" --jq .content \| base64 -d \| grep uses:` | `…/fe-release-flow.yml@$TEST_BRANCH` | +| P2 | Test branch refs point to itself | `git -C grep -nE 'collaborationFactory/github-actions/\.github/.+@' origin/$TEST_BRANCH -- .github \| grep -v "@$TEST_BRANCH"` | empty | +| P3 | Test branch is current | `git log --oneline origin/$TEST_BRANCH..origin/` | empty (the feature branch has nothing the test branch lacks) | +| P4 | No old callers on `BR` | the guard's grep over `BR`'s `.github/workflows` | empty | +| P5 | Registry target | the last publishing run's log (`41081346891` until a new-flow release exists): `grep -E '@cplace-next:registry='` | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm-test/` only | +| P6 | Source `publishConfig` | `git -C "$WT" grep -n '"registry"' -- '*/package.json' package.json \| grep -v cplace-npm-test` | empty | +| P7 | Repo settings | workflow permissions, `rules/branches/$BR`, branch protection | `read`, empty, `false` (as in 1.2) | +| P8 | Canary present (after S1) | contents API for `zz-tag-push-canary.yml` on `BR` | exists | +| P9 | No run in flight | `gh api "repos/$R/actions/runs?status=in_progress"` and `?status=queued` | none | +| P10 | Tools | `gh auth status`; `jf rt ping $J` | OK | +| P11 | Start state | `snapshot "$OUT/S0"`; tags = only known versions | matches the ledger (first time: `version/26.4.1` only) | + +--- + +# Part 3: Test cases + +## 3.1 Expected-state rules + +Each case states its outcome: **RELEASE(N)**, **NO-RELEASE**, **REJECTED** (guard or startup failure), or a case-specific outcome. `S0`/`S1` are the before and after snapshots, and `RUN` is the release-flow run. + +**RELEASE(N)** + +| Row | Rule | Evidence | +|---|---|---| +| E1 Run | Exactly one "Frontend Release CI" run for the trigger. Conclusion `success`. Jobs `guard`, `install-deps`, `build (*)`, `release`, `github-release` are all `success`. | `jobs.tsv` | +| E2 Version | `N` = the highest `version/26.4.*` patch in S0 + 1 (a forced run: the same). The log says `The calculated new tag/version for branch release/26.4 is N`. | release job log | +| E3 Tag | S1.tags = S0.tags + `refs/tags/version/N`, type `commit` (lightweight), SHA = `RUN.head_sha`. No other tag added or moved. | `tags.txt` diff | +| E4 Publish set | The new tarballs in S1 (S1 − S0 in `npm-tgz.tsv`) are exactly `

-N.tgz` for every package in the log's `+ @cplace-next/

@N` lines. Their count equals the publishable project count. It equals the previous release's count unless the replayed commit added or removed a project (then explain). All `created` timestamps fall inside the run window. | `npm-tgz.tsv`, log | +| E5 Dist-tags | For every published package, `release-26.4` → `N`. `latest` and `snapshot` are unchanged from S0. | `dist-tags.tsv` diff | +| E6 GitHub release | A release `version/N` exists, `draft=false`, `pre=false`, author `github-actions[bot]`. `latest-release.txt` = `version/N` when `is_latest` (the log/output `is_latest=true`). The body has the compare range `version/PREV...version/N`. If S0 had no release yet, the range still starts at `PREV` (`--notes-start-tag`). | `releases.txt`, `gh api repos/$R/releases/tags/version/N --jq .body` | +| E7 Tag trigger | Phase A: exactly one canary run for `version/N` with actor `squad-fe`. Phase B: **no** run of any workflow for `version/N` (`runs_since T0`, `head_branch == version/N` → none). | `runs_since` | +| E8 No residue | No `version/N` artifacts outside `@cplace-next/*`, and no other version of any `@cplace-next/*` package created in the window | AQL with the time window | + +**NO-RELEASE** + +| Row | Rule | +|---|---| +| N1 | Run `success`. `release` is `success` with the reason line, or `skipped` (non-forced dispatch). `github-release` is `skipped`. | +| N2 | S1 = S0 for `tags.txt`, `releases.txt`, `npm-tgz.tsv` and `dist-tags.tsv`. No canary run. | + +**REJECTED** + +| Row | Rule | +|---|---| +| X1 | Run `failure` (guard), or `startup_failure`/`failure` with **no** job started (permission validation). The expected error text appears in the job log or in the run annotations (`gh api repos/$R/check-runs//annotations`, or `gh run view -R $R`). | +| X2 | S1 = S0, as in N2 | + +## 3.2 Case overview + +| ID | Phase | Ticket | Trigger | Outcome | Needs | +|---|---|---|---|---|---| +| A1 | A | 34283 | push without project changes | NO-RELEASE | – (observed twice already) | +| A2 | A | 34283 | push of replay #1 | RELEASE(26.4.2) | replay #1 | +| A3 | A | 34283 | dispatch, `force=false` | NO-RELEASE (build only) | – | +| A4 | A | 34283 | dispatch, `force=true`, nothing affected | RELEASE | – | +| A5 | A | 34283 | dispatch on a `test/*` branch | REJECTED (guard ref check) | throwaway branch | +| A6 | A | 34283 | push with an old caller in the tree | REJECTED (guard old-caller check) | throwaway `release/99.9` | +| A7 | A | 34283 | push of a `[branch-off]` tip commit | NO-RELEASE (skipped) | – | +| A8 | A | 34283 | forced release, a publish fails midway, then "Re-run failed jobs" | cleanup, then RELEASE (same N) | placeholder package | +| A9 | A | 34283 | forced release cancelled during publish, then re-run | cleanup, then RELEASE (same N) | – | +| A10 | A | 34283 | re-run of `github-release` only (release exists, then deleted) | idempotent / recreated | a RELEASE run | +| A11 | A | 34283 | three pushes in quick succession | 1 RELEASE + 1 cancelled pending + 1 RELEASE/NO-RELEASE | replays #2–#4 | +| A12 | A | 34283 | push of a new older line `release/26.3` | RELEASE(26.3.1) with `latest=false` | optional | +| A13 | A | 34283 | dispatch `force=true` on `main` | snapshot of all projects, no tag, no release | optional, S3 | +| B0 | B | 34282 | preflight after S2 | – | 34282 test branch | +| B1 | B | 34282 | push of replay #5 | RELEASE with **no tag-triggered run** | replay #5 | +| B2 | B | 34282 | per-job token permissions in B1's logs | only `release`/`github-release` have write | B1 | +| B3 | B | 34282 | release without the PAT secret | RELEASE | optional; secret change (stop) | +| B4 | B | 34282 | caller ceiling below the job's request | REJECTED at startup | throwaway `test/*` | +| B5 | B | 34282 | A8 repeated (failure cleanup with `GITHUB_TOKEN`) | as A8 | placeholder package | +| B6 | B | 34282 | A10 repeated (re-run with a fresh `GITHUB_TOKEN`) | as A10 | a B release | +| B7 | B | 34282 | A4 repeated (forced release) | RELEASE, no tag-triggered run | – | + +Version numbers in the cases assume the order above starting from S0 = {26.4.1}. Each case computes `N` from its own S0, so the order may change. + +## 3.3 Phase A: consolidated flow, PAT tag push (baseline) + +### A1: Push with nothing affected + +- **Purpose:** a push to `BR` that changes no project creates no release. +- **Evidence so far:** runs `41088060031` and `41432167152`. Both log `No projects are affected since version/26.4.1 therefore no new Patch version is needed`, and `github-release` was skipped. +- **Trigger:** the S1 canary push counts as a third observation. +- **Expected:** NO-RELEASE (N1, N2). The release job log contains `Checking affected projects since version/` and the "no new Patch version" line. + +### A2: Release on a real change (core case) + +- **Purpose:** one push → one run that publishes, tags, and creates the GitHub release. No second run is needed. +- **Trigger:** + ```bash + git -C "$WT" cherry-pick -m 1 2b5dc05cc61 && git -C "$WT" push origin HEAD:$BR + SHA=$(git -C "$WT" rev-parse HEAD) + ``` +- **Expected:** RELEASE(26.4.2), E1–E8. + - E7 in Phase A: one canary run for `version/26.4.2`, actor `squad-fe`. This is the PAT baseline for B1. + - The release job log shows `Publish phase: publishing all publishable projects as 26.4.2` and then `Tag phase: pushing version/26.4.2`. Publish lines come before the tag line. +- **Also record:** + - The publish order (the `+ @cplace-next/` lines in order). A8 uses the last package. + - The total duration of the `release` job, used for the A9 timing. + +### A3: Dispatch without force + +- **Trigger:** `gh workflow run fe-release.yml -R $R --ref $BR -f force=false` +- **Expected:** NO-RELEASE. + - `guard`, `install-deps` and `build` succeed. + - `release` is **skipped** (its `if:` needs a push or `FORCE`). `github-release` is skipped. + +### A4: Forced dispatch with nothing affected + +- **Purpose:** `force` is the supported way to release without a change (it replaces hand-made tags). +- **Precondition:** nothing affected since `PREV`. True right after A2 or A3. +- **Trigger:** `gh workflow run fe-release.yml -R $R --ref $BR -f force=true` +- **Expected:** RELEASE(PREV+1), E1–E8. + - The log contains `Force is set: skipping the affected check`. + - E3: the tag sits on the dispatched branch head, and `RUN.head_sha` = the `BR` tip at dispatch. + +### A5: Dispatch on a non-release branch + +- **Trigger:** + ```bash + git -C "$WT" push origin $BR:refs/heads/test/fe-release-dispatch + gh workflow run fe-release.yml -R $R --ref test/fe-release-dispatch -f force=true + ``` + `test/*` is not a push trigger of the caller, so only the dispatch runs. +- **Expected:** REJECTED. + - `guard` fails with `::error::The release flow can only be dispatched on the default branch or a release/* branch, not on refs/heads/test/fe-release-dispatch`. + - No other job runs, and S1 = S0. +- **Cleanup:** delete the branch (`git push origin :test/fe-release-dispatch`). + +### A6: Old caller left in the tree + +- **Purpose:** the guard stops a run when an old tag-push caller exists. Otherwise a leftover file would double-publish with a PAT-pushed tag. +- **Setup:** branch `release/99.9` from the `BR` tip, plus one commit that adds `.github/workflows/zz-old-caller.yml`. + - That file is `on: workflow_dispatch` only, with one job `uses: collaborationFactory/github-actions/.github/workflows/fe-tag.yml@test/PFM-ISSUE-34283-release-flow`, so it never runs by itself. + - The commit subject is `[branch-off] live test: old caller`. If the guard missed the file, the tool would still skip tagging and publishing on the brand-new line `99.9` (A7 logic), so the test cannot release `99.9.1`. +- **Trigger:** `git -C "$WT" push origin HEAD:refs/heads/release/99.9` +- **Expected:** REJECTED. + - `guard` fails with `Old release callers found: .github/workflows/zz-old-caller.yml`. + - No tag `version/99.9.*` and no `*-99.9.*` tarball. +- **Cleanup:** delete `release/99.9`. Reset `WT` to `origin/$BR`. + +### A7: `[branch-off]` tip commit + +- **Trigger:** + ```bash + git -C "$WT" commit --allow-empty -m "[branch-off] live test" && git -C "$WT" push origin HEAD:$BR + ``` +- **Expected:** NO-RELEASE. The release job log contains `The subject of the tip commit is '[branch-off] live test'` and `Tip commit is marked with [branch-off], skipping tagging and publishing`. +- **Note:** the empty commit stays on `BR`. It changes no project, so it doesn't affect later cases. + +### A8: Publish fails midway; cleanup; re-run releases the same version + +- **Purpose:** live check of the "publish before tag" design. + - A partial publish is unpublished. + - No tag or release is left. + - "Re-run failed jobs" releases the **same** `N`. + - The JFrog identity may delete release versions. +- **Setup** (stop: show the planned upload). Let `N` = the next version and `LAST` = the last package in A2's publish order. + - Build a placeholder tarball for `@cplace-next/LAST@N` and deploy it, so the real `npm publish` of `LAST` fails with "cannot publish over existing version" after the other packages are published: + ```bash + d="$OUT/A8/placeholder"; mkdir -p "$d/package" + printf '{"name":"@cplace-next/%s","version":"%s","description":"live-test placeholder A8"}\n' "$LAST" "$N" > "$d/package/package.json" + tar -C "$d" -czf "$d/$LAST-$N.tgz" package + jf rt u $J "$d/$LAST-$N.tgz" "cplace-npm-test/@cplace-next/$LAST/-/@cplace-next/$LAST-$N.tgz" + ``` + - Verify the placeholder is visible: `jf rt curl $J -s /api/npm/cplace-npm-test/@cplace-next%2F$LAST` lists `N`. Wait for metadata recalculation if not. +- **Trigger:** `gh workflow run fe-release.yml -R $R --ref $BR -f force=true` +- **Expected, attempt 1:** + - Run `failure`. In the `release` job, "Publish to Jfrog NPM Registry and Tag" fails on `LAST`. The log shows at least one `+ @cplace-next/…@N` before it. + - "Check for an incomplete release" logs `version/N was not pushed: unpublishing its artifacts`. + - "Unpublish the incomplete release" runs and logs `Deleted artifact from Jfrog: …@N` for each published package and the placeholder. + - `github-release` is skipped. + - S1: no `version/N` tag and no release. The AQL for `*-N.tgz` under `@cplace-next` returns **0**, and dist-tags are unchanged from S0. + - A `Deleted artifact` line missing for a published package, or a JFrog 403 on delete, is a FAIL: the JFrog user lacks delete rights. Record it. +- **Trigger 2:** capture attempt 1, then `gh run rerun $RUN -R $R --failed`. +- **Expected, attempt 2:** RELEASE(N), E1–E8 (E1 for attempt 2). The same `N` as attempt 1, not `N+1`. +- **If attempt 1 leaves residue** (tarballs at `N`): stop. Teardown must delete them (4.2) before anything else. + +### A9: Cancel during publish; cleanup; re-run + +- **Trigger:** + 1. `gh workflow run fe-release.yml -R $R --ref $BR -f force=true` and find `RUN`. + 2. Poll the jobs API until step "Publish to Jfrog NPM Registry and Tag" of `release` is `in_progress`. + 3. Then poll the AQL for `*-N.tgz` until **at least 3** tarballs exist. + 4. `gh run cancel $RUN -R $R`. +- **Expected after the cancel:** + - Run `cancelled`. The "Check for an incomplete release" and "Unpublish the incomplete release" steps ran (`cancelled()` branch). + - The AQL for `*-N.tgz` returns 0. No tag, no release. +- **If the cancel lands after the tag push**, the cleanup must log `version/N is already pushed, the release is complete: nothing to clean up`. Record which case occurred, and evaluate E3–E5 instead of the zero rows. The flow is correct either way, but only the first case tests the cleanup. Repeat once if it lands after the tag. +- **Trigger 2:** `gh run rerun $RUN -R $R --failed` (cancelled jobs count as failed). +- **Expected:** RELEASE(N) with the same `N`. + +### A10: `github-release` re-run (recovery path) + +- **Purpose:** check the README recovery step "Re-run failed jobs" for a failed `github-release`, and that the step is idempotent. +- **Precondition:** a successful RELEASE run `RUN` with tag `version/N` and a release (for example A4's run). +- **Steps:** + 1. Run `gh run rerun $RUN -R $R --job `. + - **Expected:** `success`, log `A GitHub release for version/N already exists, nothing to do`, and `releases.txt` unchanged. + 2. Delete only the release: `gh release delete version/N -R $R -y`. Keep the tag. Re-run the same job again. + - **Expected:** `success`. The release `version/N` is recreated: author `github-actions[bot]`, same compare range as before, `latest` as before. + - The release job is **not** re-run, so no new publish happens and E4 is unchanged. + +### A11: Burst of three pushes (concurrency, affected base) + +- **Purpose:** + - A running release is never cancelled. + - Only one run waits, and a newer push replaces the waiting run. + - Because affected is computed against the previous **tag**, the replaced run's changes are not lost. +- **Trigger:** replays #2, #3 and #4, each pushed separately, about 15 s apart, so push 1's run is still in progress: + ```bash + for c in 9830623eecd 1df8cfc88e5 a1f33f048dc; do + git -C "$WT" cherry-pick -m 1 $c 2>/dev/null || git -C "$WT" cherry-pick $c + git -C "$WT" push origin HEAD:$BR; git -C "$WT" rev-parse HEAD >> "$OUT/A11/shas.txt"; sleep 15 + done + ``` + Use `-m 1` for merges only. Check each commit's parent count first: `git rev-list --parents -n1 `. +- **Expected:** + - Run 1 (SHA1): RELEASE(N) with the tag on SHA1. + - Run 2 (SHA2): `cancelled` while still waiting, never started (no job ran). + - Run 3 (SHA3): starts only after run 1 completes. Compare the `run_started_at`/job timestamps. + - Run 3's log: `Checking affected projects since version/N`. + - If cf-editor-lib-e2e or other projects are affected: RELEASE(N+1), tag on **SHA3**, and the GitHub release range `version/N...version/N+1` covers SHA2 and SHA3. Otherwise NO-RELEASE, and the log's affected list explains why. + - No run is in progress twice in the group at any time. +- **Evidence:** the three `run.json` files (`status`, `conclusion`, `run_started_at`, `updated_at`) and `jobs.tsv`. + +### A12 (optional): Older line, not latest + +- **Trigger:** `git -C "$WT" push origin $BR:refs/heads/release/26.3`. The line is new, so the tool releases its first version. +- **Expected:** + - RELEASE(26.3.1): all publishable projects, dist-tag `release-26.3`. + - E6 with `is_latest=false`: `latest-release.txt` is unchanged and still the newest `26.4.x`. + - `release-26.4` is unchanged. +- **Caution:** run this after all 26.4 cases. It leaves a second line, and later teardown must remove it. + +### A13 (optional): Forced dispatch on `main` (snapshot path) + +- **Precondition:** S3 has been done. +- **Trigger:** `gh workflow run fe-release.yml -R $R --ref main -f force=true` +- **Expected:** + - `release` runs and logs `Forced release flow on main: publishing a snapshot of all projects`. + - Every publishable package gets a new `0.0.0-SNAPSHOT-…` version with dist-tag `snapshot` (and `latest` if that is the tool's snapshot behaviour; take S0 as reference). + - **No** tag and **no** GitHub release. `github-release` is skipped. + - Snapshot tarballs are filtered out of `npm-tgz.tsv`, so for this case search them with `-i snapshot` and the time window. + +## 3.4 Phase B: `GITHUB_TOKEN` with job-scoped permissions (PFM-ISSUE-34282) + +These cases run after the 34282 implementation exists. They assume the target state from the [34282 research](../2026-10-26_PFM-ISSUE-34282_github-token-scoped-permissions/research.md): +- The `release` job checks out without `token:` and declares `contents: write`. +- `GIT_USER_TOKEN` is no longer declared in `fe-release-flow.yml`. +- The other jobs stay `contents: read`; `github-release` keeps `contents: write`. + +**Align the expectations with the final 34282 design before running Phase B.** In particular: whether `checks`/`pull-requests` are added, and whether the `GIT_USER*` identity step is removed. + +### B0: Switch and preflight + +- Do S2 (stop). +- **Run P1–P11** with `TEST_BRANCH=test/PFM-ISSUE-34282-`. +- **Run a static check of the test branch:** + - `fe-release-flow.yml` has no `GIT_USER_TOKEN`. + - The `release` checkout has no `token:`. + - The `release` job `permissions` = the design's set. +- **The S2 push itself is an A1-type run.** Expect NO-RELEASE. It must **not** fail at startup: the caller ceiling `contents: write` must cover every job request. + +### B1: Release on a real change, tag pushed with `GITHUB_TOKEN` + +- **Trigger:** replay #5: `git -C "$WT" cherry-pick e2d33835d4b && git -C "$WT" push origin HEAD:$BR` +- **Expected:** RELEASE(N), E1–E6 and E8 as in A2, plus: + - **E7 (Phase B):** no canary run, and no run of any workflow with `head_branch == version/N`. This is the observable difference from A2, where the canary ran as `squad-fe`. Before evaluating, wait at least 3 minutes after the tag push so a late run would show up. + - The tag exists and points at `SHA` (E3), so the push with `GITHUB_TOKEN` succeeded. + - The `release` job log has no authentication error on `git push` or `git ls-remote`. +- **If the design removed the git identity step:** the tag is created (lightweight tags need no identity), and the log contains no `Please tell me who you are`. + +### B2: Token permissions per job + +- **Source:** B1's job logs. Each job log starts with a `GITHUB_TOKEN Permissions` group listing the effective scopes. + ```bash + for f in "$OUT/B1"/run-*/job-*.log; do echo "== $f"; sed -n '/GITHUB_TOKEN Permissions/,/##\[endgroup\]/p' "$f" | sed 's/^[0-9TZ:.-]* //'; done + ``` +- **Expected:** + - `guard`, `install-deps`, `build (*)`: `Contents: read` and `Metadata: read` only. + - `release`: `Contents: write` and `Metadata: read`, plus only the scopes the 34282 design adds. + - `github-release`: `Contents: write` and `Metadata: read`. + - **No job** has `Checks`, `PullRequests`, `Issues` or `Actions` unless the design lists them. +- **Phase A comparison:** A2's `release` job showed `Contents: read`, because the PAT carried the push. Record both side by side. + +### B3 (optional, stop): Release without the PAT secret + +- **Purpose:** the strongest proof that the PAT is out of the tag and release path. +- **Precondition:** the user confirms that the `GIT_USER_TOKEN` value can be restored, or is no longer needed in `R`. Phase A cannot run again without it, because the 34283 flow declares it as required. +- **Step:** `gh secret delete GIT_USER_TOKEN -R $R`, then run A4's trigger (forced dispatch). +- **Expected:** RELEASE, E1–E8 with E7 Phase B. + - A startup error like `Secret GIT_USER_TOKEN is required, but not provided` is a FAIL: the declaration is still there. +- **Restore** (if wanted): `gh secret set GIT_USER_TOKEN -R $R` (the user supplies the value). + +### B4: Caller ceiling below the job request (negative) + +- **Purpose:** the release job's write scope comes from its own job-level block, inside the caller's ceiling. A caller that grants less is rejected before anything runs. This is the failure every consumer would see if the template ceiling were wrong. +- **Setup:** branch `test/ceiling` from `BR`, plus one commit that changes `fe-release.yml`'s top-level `permissions` to `contents: read`. +- **Trigger:** `gh workflow run fe-release.yml -R $R --ref test/ceiling -f force=false` + - Using `test/*` plus a dispatch without force is safe: if the startup check passed, the guard would fail on the ref. If the guard also passed, nothing is published without force. +- **Expected:** REJECTED at startup. + - No job started (`jobs.tsv` empty or no job with a `started_at`). + - The run error says the nested job `release` (and `github-release`) requests `contents: write` but is only allowed `contents: read`. + - **If instead `guard` runs and fails on the ref, the ceiling was not enforced: FAIL.** +- **Cleanup:** delete `test/ceiling`. + +### B5: Failure cleanup with `GITHUB_TOKEN` + +- Repeat A8 with `N` = the next version. +- **Additional check:** in attempt 1, "Check for an incomplete release" ran `git ls-remote` successfully with the checkout's persisted `GITHUB_TOKEN`. The log has the `was not pushed` line and no auth error. +- **Attempt 2** pushes the tag with `GITHUB_TOKEN`. E7 Phase B applies. + +### B6: `github-release` re-run with a fresh token + +- Repeat A10 on a Phase B release. **Expected:** as A10. The re-run job gets a new `GITHUB_TOKEN` and still has `contents: write`. + +### B7: Forced release + +- Repeat A4. **Expected:** as A4, with E7 Phase B (no tag-triggered run). + +--- + +# Part 4: Cleanup and teardown + +## 4.1 After each case + +Throwaway branches (`test/fe-release-dispatch`, `release/99.9`, `test/ceiling`) are deleted inside their case. Cumulative release state on `BR` stays during the series: tags, releases, npm versions. Later cases compute `N` from it. + +## 4.2 JFrog rule + +`cplace-npm-test` is shared. Delete only our own versions: +1. **No wildcard in a delete.** Never a whole package or repo. +2. **The expected list comes from the ledger:** the versions our runs created, and the A8/B5 placeholders if any are left. +3. **Search first** with the exact repo, path and name, and the run's time window. Show the hit list (stop). It must equal the expected list. +4. **Generate the delete spec from the checked hits**, one exact path per file. Then run a wildcard guard and a `--dry-run` whose count equals the hits. +5. **The re-search decides success** (0 hits), not `jf rt del`'s exit code. +6. **Never delete npm package metadata** by hand, and never trigger a repo-wide reindex. A version still listed in metadata a few minutes after the delete is normal (asynchronous recalculation). A listing that persists is a stop. + +Each npm version is a tarball `cplace-npm-test/@cplace-next/

/-/@cplace-next/

-.tgz`. If present, it also has a metadata JSON under `cplace-npm-test/.npm/@cplace-next/

/`. Search both paths, as in the backend procedure's npm cleanup. + +## 4.3 Teardown (end of the series, stop before each step) + +1. **Decide with the user** what stays. By default the series' versions and tags can stay, because `clone-cplace-fe` and `cplace-npm-test` are test-only. Only residue from failed cases must go. +2. **Tags and releases** in the ledger: `gh release delete -R $R -y`, then `git push origin :refs/tags/`. +3. **npm versions** in the ledger: follow 4.2. +4. **Branches.** + - Delete `release/26.3` (A12) and any leftover throwaway branches. + - To make the replay queue reusable, force-push `BR` back to its pre-series tip from `$OUT/S0` (`dac8698d3`, or the canary commit if S1 should stay). +5. **Secrets:** restore `GIT_USER_TOKEN` if B3 removed it and Phase A must stay runnable. +6. **github-actions:** delete the test branches only after the user confirms. Pushing their deletion is a stop. + +**Verify:** +- The tag and release lists equal the agreed end state. +- AQL for every deleted version returns 0. +- No run is in flight. +- `$OUT/results.md` lists every case with a verdict. + +## 4.4 Report + +`$OUT/results.md` holds: +- the case table (ID, run ids and attempts, verdict, note) +- the Phase A vs. Phase B comparison for A2/B1 (E7, the B2 permissions) and A8/B5 (cleanup) +- the deviations, each with the evidence file + +Copy the Phase A table into the PR #183 description as the live-validation evidence, and tick the plan's "live validation done or consciously deferred" checkbox ([plan.md](./plan.md), line 716). The Phase B table goes into the PFM-ISSUE-34282 PR. From 78f8eab85172734fafdfb6eb3e6b0a71416d2700 Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Mon, 2 Nov 2026 13:53:08 +0100 Subject: [PATCH 05/11] PFM-ISSUE-34283 - Let the live-test preflight ignore specs-only commits 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 --- .../clone-cplace-fe-live-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 27928272..2942476d 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -303,7 +303,7 @@ wait_run() { until [ "$(gh api repos/$R/actions/runs/$1 --jq .status)" = complet |---|---|---|---| | P1 | Caller ref | `gh api "repos/$R/contents/.github/workflows/fe-release.yml?ref=$BR" --jq .content \| base64 -d \| grep uses:` | `…/fe-release-flow.yml@$TEST_BRANCH` | | P2 | Test branch refs point to itself | `git -C grep -nE 'collaborationFactory/github-actions/\.github/.+@' origin/$TEST_BRANCH -- .github \| grep -v "@$TEST_BRANCH"` | empty | -| P3 | Test branch is current | `git log --oneline origin/$TEST_BRANCH..origin/` | empty (the feature branch has nothing the test branch lacks) | +| P3 | Test branch is current | `git diff --name-only origin/$TEST_BRANCH...origin/ -- . ':!specs'` (three dots: the feature branch's changes since the test branch forked) | empty. Feature-branch commits that touch only `specs/` are ignored; anything else means the test branch is stale and must be recreated (1.3 step 5) | | P4 | No old callers on `BR` | the guard's grep over `BR`'s `.github/workflows` | empty | | P5 | Registry target | the last publishing run's log (`41081346891` until a new-flow release exists): `grep -E '@cplace-next:registry='` | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm-test/` only | | P6 | Source `publishConfig` | `git -C "$WT" grep -n '"registry"' -- '*/package.json' package.json \| grep -v cplace-npm-test` | empty | From a10920ddb9a3c35e46ad2055c9200ba1f83b7439 Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Mon, 2 Nov 2026 14:11:44 +0100 Subject: [PATCH 06/11] PFM-ISSUE-34283 - Fix the live-test preflight and helpers after a first 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 --- .../clone-cplace-fe-live-tests.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 2942476d..04bbf7c0 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -74,7 +74,7 @@ clone-cplace-fe ──caller fe-release.yml──▶ github-actions@TEST_BRANCH | Old callers | Deleted on `release/26.4` (`push_to_release.yaml`, `on_tag_pushed.yaml`). **Still present on `main`**, so `main` is not migrated. | contents API | | Version tags | `version/26.4.1` only. It is lightweight, on `58c416208`, and was released by the **old** flow: run `41081346891` "Frontend Tag Pushed CI", actor `squad-fe` (the PAT user). | refs, runs API | | GitHub releases | None. The old flow created none, so `releases/latest` returns 404. | releases API | -| Registry state `26.4.1` | 28 `@cplace-next/*` tarballs, created 2026-10-30 15:39–15:44Z. Example dist-tags (`cf-frontend-sdk`): `release-26.4 → 26.4.1`, `snapshot`/`latest → 0.0.0-SNAPSHOT-mvv12a4h-20261030`. | AQL, npm metadata API | +| Registry state `26.4.1` | 27 `@cplace-next/*` tarballs, created 2026-10-30 15:39–15:44Z. A search on `*-26.4.1.tgz` alone finds 28: the extra one is the backend package `@cplace-assets/cplace-scim2_…-26.4.1.tgz`, so always filter on the `@cplace-next` path. Older unrelated `@cplace-next` packages from 2023 (`any-other`, `cf-fe-cdn-*`, `cf-fe-code-coverage`) also exist. Example dist-tags (`cf-frontend-sdk`): `release-26.4 → 26.4.1`, `snapshot`/`latest → 0.0.0-SNAPSHOT-mvv12a4h-20261030`. | AQL, npm metadata API | | Secrets | `DOT_NPMRC`, `GIT_USER`, `GIT_USER_EMAIL`, `GIT_USER_TOKEN`, `GRADLE_PROPERTIES`, `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`. There is no `PERCY_TOKEN`, which is fine because e2e is removed. | `gh api repos/$R/actions/secrets` | | Default workflow token | `default_workflow_permissions: read` | `gh api repos/$R/actions/permissions/workflow` | | Rulesets and protection | None on `release/26.4` or `main`, `protected: false`, no repository properties | `rules/branches/*`, `properties/values` | @@ -142,6 +142,8 @@ git -C "$WT" fetch --filter=blob:none upstream release/26.4 | Tool | Note | |---|---| +| Token for `gh` and git | Fine-grained PAT, resource owner `collaborationFactory`, repositories `github-actions` and `clone-cplace-fe` only. Repository permissions: **Contents** read/write (pushes, tags, releases); **Workflows** read/write (any push that changes `.github/workflows/*`: S1, S2, A6, B4); **Actions** read/write (runs, jobs, logs, dispatch, re-run, cancel); **Administration** read (P7 workflow-token setting); **Secrets** read/write only if B3 runs. Metadata read is implicit. Without Actions read, every run, job and log call returns `403 Resource not accessible by personal access token`. | +| `cplace-fe` (upstream) | A **separate** read-only fine-grained PAT (only `cplace-fe`, Contents read). git uses it only for that URL: `git config --global credential.https://github.com/collaborationFactory/cplace-fe.git.useHttpPath true`, then store it with `git credential approve` (`path=collaborationFactory/cplace-fe.git`). Verify: `git push --dry-run` to `cplace-fe` returns `403 Write access to repository not granted`. | | `gh` | Version 2.29 locally. Use `gh api` rather than the newer `--json` subcommands, except `gh run view --json` and `gh run rerun`/`cancel`/`watch`, which exist. | | `jf` | Version 2.53, server id `cplace.jfrog.io`. Pass `$J` on every call. | | Git Bash on Windows | `export MSYS_NO_PATHCONV=1`, or `jf rt curl /api/...` and `git show :` get their paths rewritten (seen as `curl: (3) URL rejected`). | @@ -247,14 +249,15 @@ snapshot() { gh api "repos/$R/git/matching-refs/tags/version/" --jq '.[] | "\(.ref) \(.object.type) \(.object.sha)"' | sort > "$d/tags.txt" gh api "repos/$R/releases?per_page=100" \ --jq '.[] | "\(.tag_name) draft=\(.draft) pre=\(.prerelease) author=\(.author.login)"' | sort > "$d/releases.txt" - (gh api "repos/$R/releases/latest" --jq .tag_name 2>/dev/null || echo none) > "$d/latest-release.txt" + # a 404 prints its JSON body to stdout, so keep only a tag name + (gh api "repos/$R/releases/latest" --jq .tag_name 2>/dev/null | grep -E '^version/' || echo none) > "$d/latest-release.txt" jf rt curl $J -s -XPOST /api/search/aql -H 'Content-Type: text/plain' -d \ 'items.find({"repo":"cplace-npm-test","path":{"$match":"@cplace-next/*/-/@cplace-next"},"name":{"$match":"*.tgz"}}).include("path","name","created")' \ | jq -r '.results[] | "\(.path|split("/")[1])\t\(.name)\t\(.created)"' | grep -v -i snapshot | sort > "$d/npm-tgz.tsv" cut -f1 "$d/npm-tgz.tsv" | sort -u > "$d/npm-packages.txt" : > "$d/dist-tags.tsv" while read -r p; do - jf rt curl $J -s "/api/npm/cplace-npm-test/-/package/@cplace-next%2F$p/dist-tags" \ + jf rt curl $J -s -XGET "/api/npm/cplace-npm-test/-/package/@cplace-next%2F$p/dist-tags" \ | jq -r --arg p "$p" 'to_entries[] | "\($p)\t\(.key)\t\(.value)"' >> "$d/dist-tags.tsv" done < "$d/npm-packages.txt" sort -o "$d/dist-tags.tsv" "$d/dist-tags.tsv" @@ -302,10 +305,10 @@ wait_run() { until [ "$(gh api repos/$R/actions/runs/$1 --jq .status)" = complet | ID | Check | Command (sketch) | Pass | |---|---|---|---| | P1 | Caller ref | `gh api "repos/$R/contents/.github/workflows/fe-release.yml?ref=$BR" --jq .content \| base64 -d \| grep uses:` | `…/fe-release-flow.yml@$TEST_BRANCH` | -| P2 | Test branch refs point to itself | `git -C grep -nE 'collaborationFactory/github-actions/\.github/.+@' origin/$TEST_BRANCH -- .github \| grep -v "@$TEST_BRANCH"` | empty | +| P2 | Test branch refs point to itself | `git -C grep -nE 'collaborationFactory/github-actions/\.github/.+@' origin/$TEST_BRANCH -- .github/workflows .github/actions \| grep -v "@$TEST_BRANCH"` | empty. `.github/workflow-templates` is excluded: the templates are copied into consumer repos and keep `@master` on purpose | | P3 | Test branch is current | `git diff --name-only origin/$TEST_BRANCH...origin/ -- . ':!specs'` (three dots: the feature branch's changes since the test branch forked) | empty. Feature-branch commits that touch only `specs/` are ignored; anything else means the test branch is stale and must be recreated (1.3 step 5) | | P4 | No old callers on `BR` | the guard's grep over `BR`'s `.github/workflows` | empty | -| P5 | Registry target | the last publishing run's log (`41081346891` until a new-flow release exists): `grep -E '@cplace-next:registry='` | `https://cplace.jfrog.io/artifactory/api/npm/cplace-npm-test/` only | +| P5 | Registry target | the last publishing run's job logs (`41081346891` until a new-flow release exists). The tool's printed `@cplace-next:registry=` line is masked (`JFROG_URL` is a secret), so check the unmasked paths: `grep -ohE 'cplace\.jfrog\.io/artifactory/api/npm/[A-Za-z0-9._-]+'` and `grep -E 'api/npm/cplace-npm([^-]\|$)'` | Publishes (`+ @cplace-next/

@` lines) all exist in `cplace-npm-test` (AQL). Any other npm repo in the log appears **only** in `Unknown user config` warnings: that is `~/.npmrc` from `DOT_NPMRC` (install registry, `cplace-npm`), which the dist project `.npmrc` overrides for publishing | | P6 | Source `publishConfig` | `git -C "$WT" grep -n '"registry"' -- '*/package.json' package.json \| grep -v cplace-npm-test` | empty | | P7 | Repo settings | workflow permissions, `rules/branches/$BR`, branch protection | `read`, empty, `false` (as in 1.2) | | P8 | Canary present (after S1) | contents API for `zz-tag-push-canary.yml` on `BR` | exists | From 3de109968ceca39b40700c59d66d69777a3a4fca Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Mon, 2 Nov 2026 18:07:32 +0100 Subject: [PATCH 07/11] PFM-ISSUE-34283 - Keep release-X.Y intact when a failed release is unpublished 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 --- README.md | 19 ++++- .../clone-cplace-fe-live-tests.md | 24 ++++-- .../artifacts-handler-release-flow.test.ts | 55 ++++++++++++- tools/scripts/artifacts/artifacts-handler.ts | 13 +++ tools/scripts/artifacts/nx-project.test.ts | 82 +++++++++++++++++++ tools/scripts/artifacts/nx-project.ts | 48 ++++++++++- 6 files changed, 233 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e5b4052f..9737525c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ JavaScript action runtime since June 2026). `.github/workflows/fe-release-flow.yml` releases a FE repo in **one run** per push to `release/*` (or per manual dispatch): -guard → install-deps → build ∥ e2e → release (publish + tag, one job) → GitHub release +guard → install-deps → build ∥ e2e → release (publish + promote + tag, one job) → GitHub release The `release` job computes the next `version/X.Y.Z` and checks whether any project is affected since the previous tag of the release line. If so, it publishes all publishable projects at that version, @@ -30,6 +30,17 @@ then pushes the tag. Publish before tag: a `version/*` tag always stands for a c the publish or the tag push fails, or the run is cancelled before the tag is pushed, the job unpublishes that version's artifacts again, so a re-run or the next push releases the same version. +The npm dist-tag `release-X.Y` moves only once the whole release is published: + +1. **Publish** every project under the staging tag `release-X.Y-pending`. +2. **Promote**: `npm dist-tag add @X.Y.Z release-X.Y`, then remove the staging tag. +3. **Tag**: push `version/X.Y.Z`. + +Unpublishing a version also drops every dist-tag that points to it. With the staging tag, a run +that fails while publishing leaves `release-X.Y` on the previous version of every package. (Live +test A8 showed that publishing straight under `release-X.Y` left most packages without the tag after +the cleanup.) The flow never sets `latest`. + ### Migration steps Migrate a branch of a consumer repo in **one commit**: @@ -76,6 +87,12 @@ Passed with `secrets: inherit`: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, - **Publish or tag push failed**: the "Unpublish the incomplete release" step has removed the version's artifacts. "Re-run failed jobs" (or the next push) publishes and tags the same version. +- **Promote failed** (a JFrog error while moving `release-X.Y`): the cleanup unpublishes the version + as above. The packages promoted before the error lose `release-X.Y` until "Re-run failed jobs" + promotes the same version again. +- **A staging tag `release-X.Y-pending` is left on a package**: its removal failed after the + promotion (a warning in the log). It is harmless; remove it with + `npm dist-tag rm release-X.Y-pending`. - **The cleanup did not run or failed** (lost runner, JFrog error): the next publish of that version fails with E403. Unpublish the packages of that version listed in the job log (`npm unpublish /@X.Y.Z --force`), then re-run. diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 04bbf7c0..483c457e 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -89,7 +89,7 @@ clone-cplace-fe ──caller fe-release.yml──▶ github-actions@TEST_BRANCH - On a `release/*` push, the tool computes `N` as the highest `version/.*` patch + 1. On a line with no tags it releases the first version of the line. - **Affected check:** with no project affected since `PREV`, nothing is released. If any project is affected, the publish phase publishes **all publishable projects** at `N`, not only the affected ones. `FORCE` skips the affected check. - **Order:** publish first, then `git tag` (a lightweight tag) and `git push origin `. Then the `github-release` job runs `gh release create --verify-tag --generate-notes --latest= [--notes-start-tag PREV]`. -- **npm dist-tag:** a release sets `release-`, from `Version.getNpmTag()`. `latest` and `snapshot` are not touched. +- **npm dist-tag:** a release publishes every package under the staging tag `release--pending`. After all packages are published, a promote phase moves `release-` (from `Version.getNpmTag()`) to `N` and removes the staging tag. Only then is the git tag pushed. `latest` and `snapshot` are not touched by the flow. This staging was added after A8 (first run) showed that unpublishing `N` drops a `release-` tag that already points to `N`. - A tip commit whose subject starts with `[branch-off]` is neither tagged nor published. - **Guard job:** a dispatch outside the default branch or `release/*` fails. Any workflow file that references `github-actions/.github/workflows/(fe-release|fe-tag).yml@` also fails it. - **Failure cleanup:** if the `release` job fails or is cancelled after the version file is written and before the tag exists on the remote, the "Unpublish the incomplete release" step deletes every project at `N`. @@ -135,11 +135,15 @@ Steps 1–7 were done once. Repeat them to build a fresh environment, for exampl ```bash WT="$OUT/wt-clone-cplace-fe" -git clone --filter=blob:none --branch release/26.4 https://github.com/collaborationFactory/clone-cplace-fe.git "$WT" +git -c core.longpaths=true clone --filter=blob:none --branch release/26.4 https://github.com/collaborationFactory/clone-cplace-fe.git "$WT" +git -C "$WT" config core.longpaths true +git -C "$WT" status --porcelain | wc -l # must be 0 git -C "$WT" remote add upstream https://github.com/collaborationFactory/cplace-fe.git git -C "$WT" fetch --filter=blob:none upstream release/26.4 ``` +On Windows, `core.longpaths` is required. Several cypress spec paths, plus a long `$OUT`, exceed the 260-character limit; without it the checkout ends with `Filename too long`, and some files are missing. **Never commit from such a working copy**: the missing files would be committed as deletions. Delete it and clone again. + | Tool | Note | |---|---| | Token for `gh` and git | Fine-grained PAT, resource owner `collaborationFactory`, repositories `github-actions` and `clone-cplace-fe` only. Repository permissions: **Contents** read/write (pushes, tags, releases); **Workflows** read/write (any push that changes `.github/workflows/*`: S1, S2, A6, B4); **Actions** read/write (runs, jobs, logs, dispatch, re-run, cancel); **Administration** read (P7 workflow-token setting); **Secrets** read/write only if B3 runs. Metadata read is implicit. Without Actions read, every run, job and log call returns `403 Resource not accessible by personal access token`. | @@ -242,6 +246,8 @@ Write this to `$OUT/lib.sh` and source it in every shell: ```bash export MSYS_NO_PATHCONV=1 JFROG_CLI_AVOID_NEW_VERSION_WARNING=TRUE R=collaborationFactory/clone-cplace-fe; J="--server-id cplace.jfrog.io"; LINE=26.4; BR=release/26.4 +# a native Windows jq ends lines with CRLF; a trailing \r would end up in ids and file names +jq() { command jq "$@" | tr -d '\r'; } # snapshot

: tags, releases, npm release versions and dist-tags of @cplace-next/* snapshot() { @@ -280,6 +286,10 @@ capture() { done < "$d/jobs.tsv" } +# job_log : path of that job's log (step names do not appear in job logs, +# so select by job name, e.g. "release-flow / release") +job_log() { echo "$1/job-$(awk -F'\t' -v n="$2" '$1==n{print $3}' "$1/jobs.tsv").log"; } + # wait_run : block until completed, print conclusion wait_run() { until [ "$(gh api repos/$R/actions/runs/$1 --jq .status)" = completed ]; do sleep 30; done; gh api repos/$R/actions/runs/$1 --jq .conclusion; } ``` @@ -332,7 +342,7 @@ Each case states its outcome: **RELEASE(N)**, **NO-RELEASE**, **REJECTED** (guar | E2 Version | `N` = the highest `version/26.4.*` patch in S0 + 1 (a forced run: the same). The log says `The calculated new tag/version for branch release/26.4 is N`. | release job log | | E3 Tag | S1.tags = S0.tags + `refs/tags/version/N`, type `commit` (lightweight), SHA = `RUN.head_sha`. No other tag added or moved. | `tags.txt` diff | | E4 Publish set | The new tarballs in S1 (S1 − S0 in `npm-tgz.tsv`) are exactly `

-N.tgz` for every package in the log's `+ @cplace-next/

@N` lines. Their count equals the publishable project count. It equals the previous release's count unless the replayed commit added or removed a project (then explain). All `created` timestamps fall inside the run window. | `npm-tgz.tsv`, log | -| E5 Dist-tags | For every published package, `release-26.4` → `N`. `latest` and `snapshot` are unchanged from S0. | `dist-tags.tsv` diff | +| E5 Dist-tags | For every published package, `release-26.4` → `N`, and **no** `release-26.4-pending` tag is left. `latest` and `snapshot` are unchanged from S0. The release job log has `Promote phase: moving release-26.4 to N` after the last publish line and before `Tag phase`. | `dist-tags.tsv` diff, log | | E6 GitHub release | A release `version/N` exists, `draft=false`, `pre=false`, author `github-actions[bot]`. `latest-release.txt` = `version/N` when `is_latest` (the log/output `is_latest=true`). The body has the compare range `version/PREV...version/N`. If S0 had no release yet, the range still starts at `PREV` (`--notes-start-tag`). | `releases.txt`, `gh api repos/$R/releases/tags/version/N --jq .body` | | E7 Tag trigger | Phase A: exactly one canary run for `version/N` with actor `squad-fe`. Phase B: **no** run of any workflow for `version/N` (`runs_since T0`, `head_branch == version/N` → none). | `runs_since` | | E8 No residue | No `version/N` artifacts outside `@cplace-next/*`, and no other version of any `@cplace-next/*` package created in the window | AQL with the time window | @@ -451,7 +461,8 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. git -C "$WT" commit --allow-empty -m "[branch-off] live test" && git -C "$WT" push origin HEAD:$BR ``` - **Expected:** NO-RELEASE. The release job log contains `The subject of the tip commit is '[branch-off] live test'` and `Tip commit is marked with [branch-off], skipping tagging and publishing`. -- **Note:** the empty commit stays on `BR`. It changes no project, so it doesn't affect later cases. +- **Follow-up (required):** while the `[branch-off]` commit is the tip of `BR`, **every** run on `BR` is skipped, forced dispatches included (`[branch-off]` wins over force by design). Right after A7, push an empty normal commit (`git commit --allow-empty -m "live test: end of A7"`). Its run is a NO-RELEASE: nothing is affected since `PREV`. Evaluate it with N1/N2. +- **Optional extra check (A7b):** before that follow-up commit, a forced dispatch on `BR` must also end with `Tip commit is marked with [branch-off], skipping tagging and publishing` and no state change. This was observed by accident in the first A8 attempt (run `41475222046`). ### A8: Publish fails midway; cleanup; re-run releases the same version @@ -475,7 +486,10 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. - "Check for an incomplete release" logs `version/N was not pushed: unpublishing its artifacts`. - "Unpublish the incomplete release" runs and logs `Deleted artifact from Jfrog: …@N` for each published package and the placeholder. - `github-release` is skipped. - - S1: no `version/N` tag and no release. The AQL for `*-N.tgz` under `@cplace-next` returns **0**, and dist-tags are unchanged from S0. + - S1: no `version/N` tag and no release. The AQL for `*-N.tgz` under `@cplace-next` returns **0**. + - Dist-tags: `release-26.4` is unchanged from S0 on **every** package, still `PREV`, and no `release-26.4-pending` is left. The log has no `Promote phase` line. + - First run, before the staging fix (run `41480323282`): 26 of 27 packages lost `release-26.4` entirely. + - Placeholder side effect: `jf rt u` moves the package's `latest` to the placeholder version; Artifactory moves it to the highest remaining version after the delete. Compare `latest` of `LAST` with the snapshot taken **before** the upload, and restore it in teardown. - A `Deleted artifact` line missing for a published package, or a JFrog 403 on delete, is a FAIL: the JFrog user lacks delete rights. Record it. - **Trigger 2:** capture attempt 1, then `gh run rerun $RUN -R $R --failed`. - **Expected, attempt 2:** RELEASE(N), E1–E8 (E1 for attempt 2). The same `N` as attempt 1, not `N+1`. diff --git a/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts index 72607e7a..ad79ad62 100644 --- a/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts +++ b/tools/scripts/artifacts/artifacts-handler-release-flow.test.ts @@ -71,6 +71,55 @@ test('a push to a release branch with affected projects publishes all projects a expect(output).toHaveBeenCalledWith('is_latest', 'true'); }); +test('a release publishes under the staging tag and moves release-X.Y between publishing and tagging', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const project = new NxProject('app a', NxProjectKind.Application); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([project]); + const publish = mockPublish(); + const promote = jest.spyOn(project, 'promote').mockReturnValue(); + jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await new ArtifactsHandler().handle(); + + expect(project.stageRelease).toBe(true); + expect(promote).toHaveBeenCalledTimes(1); + expect(publish.mock.invocationCallOrder[0]).toBeLessThan( + promote.mock.invocationCallOrder[0] + ); + expect(promote.mock.invocationCallOrder[0]).toBeLessThan( + tagCallOrder(execMock) + ); +}); + +test('a failed promotion fails the run without tagging, leaving the version for the cleanup step', async () => { + process.env.RELEASE_FLOW = 'true'; + const execMock = mockGit(); + mockBranch('release/26.4'); + jest + .spyOn(Utils, 'getAffectedNxProjects') + .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); + const project = new NxProject('app a', NxProjectKind.Application); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([project]); + mockPublish(); + jest.spyOn(project, 'promote').mockImplementation(() => { + throw new Error('dist-tag failed'); + }); + const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); + + await expect(new ArtifactsHandler().handle()).rejects.toThrow( + 'dist-tag failed' + ); + + expect(Utils.writeReleaseVersionFile).toHaveBeenCalledWith('version/26.4.3'); + expect(gitTagOrPushCalls(execMock)).toEqual([]); + expect(output).not.toHaveBeenCalled(); +}); + test('a push to a release branch with nothing affected neither tags nor publishes', async () => { process.env.RELEASE_FLOW = 'true'; const execMock = mockGit(); @@ -160,14 +209,18 @@ test('a failed publish fails the run without tagging, leaving the version for th jest .spyOn(Utils, 'getAffectedNxProjects') .mockReturnValue([new NxProject('lib a', NxProjectKind.Library)]); - jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([]); + const project = new NxProject('app a', NxProjectKind.Application); + jest.spyOn(Utils, 'getAllNxProjects').mockReturnValue([project]); mockPublish().mockRejectedValue(new Error('publish failed')); + const promote = jest.spyOn(project, 'promote').mockReturnValue(); const output = jest.spyOn(Utils, 'writeOutput').mockReturnValue(); await expect(new ArtifactsHandler().handle()).rejects.toThrow( 'publish failed' ); + // release-X.Y stays on the previous version + expect(promote).not.toHaveBeenCalled(); expect(Utils.writeReleaseVersionFile).toHaveBeenCalledWith('version/26.4.3'); expect(gitTagOrPushCalls(execMock)).toEqual([]); expect(output).not.toHaveBeenCalled(); diff --git a/tools/scripts/artifacts/artifacts-handler.ts b/tools/scripts/artifacts/artifacts-handler.ts index 4eee32d7..9c0d7e1f 100644 --- a/tools/scripts/artifacts/artifacts-handler.ts +++ b/tools/scripts/artifacts/artifacts-handler.ts @@ -287,11 +287,24 @@ export class ArtifactsHandler { this.currentVersion = this.calculatedNewVersion; this.onlyAffected = false; this.initProjects(); + // the cleanup step unpublishes this version on failure, which drops every dist-tag + // pointing to it: publish under a staging tag and move release-X.Y only after all + // projects are published, so a failed run leaves release-X.Y on the previous version + this.projects.forEach((project) => (project.stageRelease = true)); await this.buildAndPublishProjects(); Utils.appendStepSummary( `Publish phase: published all publishable projects as \`${this.calculatedNewVersion}\`` ); + const npmTag = this.calculatedNewVersion.getNpmTag(); + console.log( + `Promote phase: moving ${npmTag} to ${this.calculatedNewVersion}` + ); + this.projects.forEach((project) => project.promote()); + Utils.appendStepSummary( + `Promote phase: moved \`${npmTag}\` to \`${this.calculatedNewVersion}\`` + ); + console.log(`Tag phase: pushing ${gitTag}`); console.log(execSync(`git tag ${gitTag}`).toString()); console.log(execSync(`git push origin ${gitTag}`).toString()); diff --git a/tools/scripts/artifacts/nx-project.test.ts b/tools/scripts/artifacts/nx-project.test.ts index 183279e9..e9da3600 100644 --- a/tools/scripts/artifacts/nx-project.test.ts +++ b/tools/scripts/artifacts/nx-project.test.ts @@ -6,6 +6,7 @@ import { globResult, packageJsonLib1 } from './test-data'; import { Utils } from './utils'; import { sep } from 'node:path'; import { Version } from './version'; +import { TASK } from './artifacts-handler'; jest.mock('child_process'); @@ -216,3 +217,84 @@ describe('NxProject.deleteArtifact', () => { expect(exit).toHaveBeenCalledWith(1); }); }); + +describe('NxProject release dist-tags', () => { + const JFROG_REGISTRY = 'https://cplace.jfrog.io/artifactory/cplace-npm-local'; + const version = new Version('26.4.3'); + + function aReleaseApp(): NxProject { + return new NxProject( + 'my-app', + NxProjectKind.Application, + TASK.RELEASE, + version, + '@cplace-next' + ); + } + + test('a release publishes under release-X.Y unless it is staged', () => { + const project = aReleaseApp(); + expect(project.getTag()).toBe('release-26.4'); + + project.stageRelease = true; + expect(project.getTag()).toBe('release-26.4-pending'); + }); + + test('promote moves release-X.Y to the version, then removes the staging tag', () => { + (execSync as jest.Mock).mockReturnValue(Buffer.from('')); + + aReleaseApp().promote(); + + const calls = (execSync as jest.Mock).mock.calls; + expect(calls.map(([command]) => command)).toEqual([ + `npm dist-tag add @cplace-next/my-app@26.4.3 release-26.4 --registry=${JFROG_REGISTRY}`, + `npm dist-tag rm @cplace-next/my-app release-26.4-pending --registry=${JFROG_REGISTRY}`, + ]); + expect(calls[0][1].cwd).toContain('dist/apps/my-app'.replace(/\//g, sep)); + }); + + test('a failed move of release-X.Y fails the run and keeps the staging tag', () => { + const exit = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + (execSync as jest.Mock).mockImplementationOnce(() => { + throw Object.assign(new Error('Command failed'), { status: 1 }); + }); + + aReleaseApp().promote(); + + expect(exit).toHaveBeenCalledWith(1); + expect((execSync as jest.Mock).mock.calls).toHaveLength(1); + }); + + test('a staging tag that cannot be removed does not fail the release', () => { + const exit = jest.spyOn(process, 'exit'); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + (execSync as jest.Mock) + .mockReturnValueOnce(Buffer.from('')) + .mockImplementationOnce(() => { + throw Object.assign(new Error('Command failed'), { status: 1 }); + }); + + aReleaseApp().promote(); + + expect(exit).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + }); + + test('promote skips projects that are not published', () => { + // a library without a package.json is not publishable + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const lib = new NxProject( + 'my-lib', + NxProjectKind.Library, + TASK.RELEASE, + version, + '@cplace-next' + ); + + lib.promote(); + + expect(execSync).not.toHaveBeenCalled(); + }); +}); diff --git a/tools/scripts/artifacts/nx-project.ts b/tools/scripts/artifacts/nx-project.ts index 5e861dd2..99968f7b 100644 --- a/tools/scripts/artifacts/nx-project.ts +++ b/tools/scripts/artifacts/nx-project.ts @@ -33,10 +33,13 @@ export enum VERSION_BUMP { export class NxProject { public static readonly PACKAGEJSON = 'package.json'; public static readonly FOSS_LIST_FILENAME = 'cplace-foss-list.json'; + public static readonly PENDING_TAG_SUFFIX = '-pending'; private _npmrcContent = ''; private _packageJsonContent: any = {}; public isPublishable: boolean = false; public hasPackageJson = false; + // release flow: publish under release-X.Y-pending, promote() moves release-X.Y later + public stageRelease = false; private _pathToProject = ''; constructor( @@ -352,10 +355,53 @@ export class NxProject { public getTag(): string { if (this.task === TASK.PR_SNAPSHOT) return 'latest-pr-snapshot'; - if (this.task === TASK.RELEASE) return this.version.getNpmTag(); + if (this.task === TASK.RELEASE) + return this.stageRelease + ? this.version.getNpmTag() + NxProject.PENDING_TAG_SUFFIX + : this.version.getNpmTag(); return 'snapshot'; } + /** + * Moves the release dist-tag (release-X.Y) of a staged release to this version + * and removes the staging tag. Unpublishing a version also drops every dist-tag + * that points to it, so release-X.Y may only move once the whole release is + * published: a failed run then leaves it on the previous version. + */ + public promote() { + if (!this.isPublishable) return; + const scopedPackage = `${this.scope}/${this.name}`; + const releaseTag = this.version.getNpmTag(); + const options = { cwd: this.getPathToProjectInDist() }; + try { + console.log( + execSync( + `npm dist-tag add ${scopedPackage}@${this.version.toString()} ${releaseTag} --registry=${getJfrogUrl()}`, + options + ).toString() + ); + } catch (error: any) { + console.error( + `An error occurred while moving ${releaseTag} to ${scopedPackage}@${this.version.toString()}: ${error}` + ); + if (error.status !== 0) process.exit(1); + return; + } + // only cosmetic once release-X.Y moved: a leftover staging tag must not fail the release + try { + execSync( + `npm dist-tag rm ${scopedPackage} ${releaseTag}${ + NxProject.PENDING_TAG_SUFFIX + } --registry=${getJfrogUrl()}`, + options + ); + } catch (error: any) { + console.warn( + `Could not remove the staging tag of ${scopedPackage}: ${error}` + ); + } + } + public getPrettyPackageJson(): string { return JSON.stringify(this.packageJsonContent, null, 2); } From 37dc554239eddbe01b019c54c6de3f996939fd5f Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Tue, 3 Nov 2026 09:43:34 +0100 Subject: [PATCH 08/11] PFM-ISSUE-34283 - Check each unpublish of the release cleanup, document 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 --- README.md | 16 ++- .../clone-cplace-fe-live-tests.md | 28 +++-- .../artifacts/cleanup-snapshots.test.ts | 3 + tools/scripts/artifacts/nx-project.test.ts | 112 +++++++++++++++--- tools/scripts/artifacts/nx-project.ts | 62 ++++++++-- 5 files changed, 178 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9737525c..96e42cfc 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ guard → install-deps → build ∥ e2e → release (publish + promote + tag, o The `release` job computes the next `version/X.Y.Z` and checks whether any project is affected since the previous tag of the release line. If so, it publishes all publishable projects at that version, then pushes the tag. Publish before tag: a `version/*` tag always stands for a complete release. If -the publish or the tag push fails, or the run is cancelled before the tag is pushed, the job -unpublishes that version's artifacts again, so a re-run or the next push releases the same version. +the publish or the tag push fails, the job unpublishes that version's artifacts again, so a re-run +or the next push releases the same version. Do not cancel a running release: see Recovery. The npm dist-tag `release-X.Y` moves only once the whole release is published: @@ -93,8 +93,18 @@ Passed with `secrets: inherit`: `JFROG_BASE64_TOKEN`, `JFROG_URL`, `JFROG_USER`, - **A staging tag `release-X.Y-pending` is left on a package**: its removal failed after the promotion (a warning in the log). It is harmless; remove it with `npm dist-tag rm release-X.Y-pending`. +- **A release run was cancelled while publishing**: not supported, the result is not consistent. + The cancel ends the publish step, but not the publisher process. It keeps building and publishing + until the job ends, also while the cleanup step runs. It can leave packages of that version behind + and, in the worst case, still move `release-X.Y` and push the tag. Live test A9 left 3 of 27 + packages behind. Check the tag first: if `version/X.Y.Z` exists, the release is complete. If not, + use "Re-run failed jobs": the publish fails on the first leftover package, and the cleanup of that + attempt unpublishes all packages of the version. Re-run failed jobs again to release it. A + cancel before the publish step starts is safe. - **The cleanup did not run or failed** (lost runner, JFrog error): the next publish of that version - fails with E403. Unpublish the packages of that version listed in the job log + fails with E403. The cleanup checks every unpublish: `npm unpublish` can report success and delete + nothing (live test A9). A version still in the registry is unpublished once more; if it survives + that too, the step fails with "still in the registry after two npm unpublish attempts". Unpublish the packages of that version listed in the job log (`npm unpublish /@X.Y.Z --force`), then re-run. - **Only `github-release` failed**: use **"Re-run failed jobs"**. It reuses the tag of the `release` job and creates the GitHub release (it does nothing if the release already exists). Do not use diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 483c457e..49d755e1 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -92,7 +92,7 @@ clone-cplace-fe ──caller fe-release.yml──▶ github-actions@TEST_BRANCH - **npm dist-tag:** a release publishes every package under the staging tag `release--pending`. After all packages are published, a promote phase moves `release-` (from `Version.getNpmTag()`) to `N` and removes the staging tag. Only then is the git tag pushed. `latest` and `snapshot` are not touched by the flow. This staging was added after A8 (first run) showed that unpublishing `N` drops a `release-` tag that already points to `N`. - A tip commit whose subject starts with `[branch-off]` is neither tagged nor published. - **Guard job:** a dispatch outside the default branch or `release/*` fails. Any workflow file that references `github-actions/.github/workflows/(fe-release|fe-tag).yml@` also fails it. -- **Failure cleanup:** if the `release` job fails or is cancelled after the version file is written and before the tag exists on the remote, the "Unpublish the incomplete release" step deletes every project at `N`. +- **Failure cleanup:** if the `release` job fails or is cancelled after the version file is written and before the tag exists on the remote, the "Unpublish the incomplete release" step deletes every project at `N` and checks each deletion. A cancel during the publish can leave packages behind (A9, accepted limitation). ## 1.3 How the environment was built (recipe for rebuilding) @@ -275,11 +275,12 @@ runs_for() { gh api "repos/$R/actions/runs?head_sha=$1&event=$2&per_page=20" --j # runs_since : every run created since (e.g. the canary on a tag) runs_since() { gh api "repos/$R/actions/runs?created=>=$1&per_page=50" --jq '.workflow_runs[] | "\(.id)\t\(.name)\t\(.event)\t\(.head_branch)\t\(.actor.login)\t\(.status)/\(.conclusion)"'; } -# capture

: run metadata, jobs, full logs +# capture [attempt]: run metadata, jobs, full logs of ONE attempt (default: the latest). +# jobs?filter=all would mix the jobs of all attempts, so every job name would appear twice after a re-run. capture() { - local run=$1 d=$2; mkdir -p "$d" - gh api "repos/$R/actions/runs/$run" > "$d/run.json" - gh api "repos/$R/actions/runs/$run/jobs?filter=all&per_page=100" > "$d/jobs.json" + local run=$1 d=$2 n=${3:-$(gh api "repos/$R/actions/runs/$1" --jq .run_attempt)}; mkdir -p "$d" + gh api "repos/$R/actions/runs/$run/attempts/$n" > "$d/run.json" + gh api "repos/$R/actions/runs/$run/attempts/$n/jobs?per_page=100" > "$d/jobs.json" jq -r '.jobs[] | "\(.name)\t\(.conclusion)\t\(.id)"' "$d/jobs.json" > "$d/jobs.tsv" while IFS=$'\t' read -r name _ id; do gh api "repos/$R/actions/jobs/$id/logs" > "$d/job-$id.log" 2>/dev/null || true @@ -373,7 +374,7 @@ Each case states its outcome: **RELEASE(N)**, **NO-RELEASE**, **REJECTED** (guar | A6 | A | 34283 | push with an old caller in the tree | REJECTED (guard old-caller check) | throwaway `release/99.9` | | A7 | A | 34283 | push of a `[branch-off]` tip commit | NO-RELEASE (skipped) | – | | A8 | A | 34283 | forced release, a publish fails midway, then "Re-run failed jobs" | cleanup, then RELEASE (same N) | placeholder package | -| A9 | A | 34283 | forced release cancelled during publish, then re-run | cleanup, then RELEASE (same N) | – | +| A9 | A | 34283 | forced release cancelled during publish, then re-run | leftovers possible (accepted), recovery by re-runs, then RELEASE (same N) | – | | A10 | A | 34283 | re-run of `github-release` only (release exists, then deleted) | idempotent / recreated | a RELEASE run | | A11 | A | 34283 | three pushes in quick succession | 1 RELEASE + 1 cancelled pending + 1 RELEASE/NO-RELEASE | replays #2–#4 | | A12 | A | 34283 | push of a new older line `release/26.3` | RELEASE(26.3.1) with `latest=false` | optional | @@ -502,12 +503,17 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. 2. Poll the jobs API until step "Publish to Jfrog NPM Registry and Tag" of `release` is `in_progress`. 3. Then poll the AQL for `*-N.tgz` until **at least 3** tarballs exist. 4. `gh run cancel $RUN -R $R`. +- **Known limitation (accepted, 2026-11-03):** a cancel during the publish is not supported (README, Recovery). The cancel ends the step's shell, but `npx ts-node main.ts` survives as an orphan and keeps publishing until the job ends ("Terminate orphan process" at the end of the job log), also while the cleanup runs. Nothing cancels a running release by itself: the caller has `cancel-in-progress: false` and the job no `timeout-minutes`. The case therefore checks the documented recovery, not a clean cancel. - **Expected after the cancel:** - - Run `cancelled`. The "Check for an incomplete release" and "Unpublish the incomplete release" steps ran (`cancelled()` branch). - - The AQL for `*-N.tgz` returns 0. No tag, no release. -- **If the cancel lands after the tag push**, the cleanup must log `version/N is already pushed, the release is complete: nothing to clean up`. Record which case occurred, and evaluate E3–E5 instead of the zero rows. The flow is correct either way, but only the first case tests the cleanup. Repeat once if it lands after the tag. -- **Trigger 2:** `gh run rerun $RUN -R $R --failed` (cancelled jobs count as failed). -- **Expected:** RELEASE(N) with the same `N`. + - Run `cancelled`. The "Check for an incomplete release" and "Unpublish the incomplete release" steps ran (`cancelled()` branch) and deleted the packages published before the cancel. + - Packages published by the orphan during or after the cleanup may be left at `N`, each with `release-26.4-pending`. First run (`41504160011`): 3 of 27. + - No tag, no release. `release-26.4` unchanged on every package. +- **If the cancel lands after the tag push**, the cleanup must log `version/N is already pushed, the release is complete: nothing to clean up`. Record which case occurred, and evaluate E3–E5 instead. Repeat once if it lands after the tag. +- **Trigger 2 (recovery):** `gh run rerun $RUN -R $R --failed` (cancelled jobs count as failed). +- **Expected, attempt 2, if packages were left:** `failure` on the first leftover package ("cannot publish over the previously published versions"). The cleanup unpublishes every package of `N`, the leftovers included: AQL `*-N.tgz` = 0, no tag, `release-26.4` unchanged. + - First run: `cf-devkit` survived an unpublish that npm reported as successful; a local `npm unpublish` removed it (all HTTP 200, not reproducible). Since then the cleanup checks each deletion and retries once (README, Recovery). +- **Trigger 3:** `gh run rerun $RUN -R $R --failed` again (only if attempt 2 failed). +- **Expected, last attempt:** RELEASE(N) with the same `N`, E1–E8 plus E5b/E5c. ### A10: `github-release` re-run (recovery path) diff --git a/tools/scripts/artifacts/cleanup-snapshots.test.ts b/tools/scripts/artifacts/cleanup-snapshots.test.ts index 86e7cfba..ca2f1e71 100644 --- a/tools/scripts/artifacts/cleanup-snapshots.test.ts +++ b/tools/scripts/artifacts/cleanup-snapshots.test.ts @@ -3,6 +3,7 @@ import { expect } from '@jest/globals'; import { CleanupSnapshots } from './cleanup-snapshots'; import { npmSearchResult, npmViewResult } from './test-data'; import { Utils } from './utils'; +import { NxProject } from './nx-project'; beforeEach(() => { jest.resetAllMocks(); @@ -16,6 +17,8 @@ test('can find latest version Tag of a given branch and bump the version', async .spyOn(child_process, 'execSync') .mockReturnValueOnce(npmSearchResult) .mockReturnValue(npmViewResult); + // the deletion itself is covered in nx-project.test.ts + jest.spyOn(NxProject.prototype, 'deleteArtifact').mockResolvedValue(); jest.useFakeTimers().setSystemTime(new Date('2023-05-22')); const cleanupSnaps = new CleanupSnapshots(); await cleanupSnaps.deleteSuperfluousArtifacts(); diff --git a/tools/scripts/artifacts/nx-project.test.ts b/tools/scripts/artifacts/nx-project.test.ts index e9da3600..d6d79d5a 100644 --- a/tools/scripts/artifacts/nx-project.test.ts +++ b/tools/scripts/artifacts/nx-project.test.ts @@ -142,19 +142,35 @@ describe('NxProject.deleteArtifact', () => { }); } + function npmShowLists(...versions: string[]) { + (execSync as jest.Mock).mockReturnValueOnce( + Buffer.from(JSON.stringify({ name: '@cplace-next/my-app', versions })) + ); + } + + function npmUnpublishReportsSuccess() { + (execSync as jest.Mock).mockReturnValueOnce( + Buffer.from('- @cplace-next/my-app@0.0.0-my-branch-46') + ); + } + + function commands(): string[] { + return (execSync as jest.Mock).mock.calls.map(([command]) => command); + } + + const SHOW = `npm show @cplace-next/my-app --json --registry=${JFROG_REGISTRY}`; + const UNPUBLISH = + 'npm unpublish @cplace-next/my-app@0.0.0-my-branch-46 --force'; + beforeEach(() => { jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(NxProject, 'sleep').mockResolvedValue(); }); test('looks the version up in the JFrog registry, not the public one', async () => { - (execSync as jest.Mock).mockReturnValue( - Buffer.from( - JSON.stringify({ - name: '@cplace-next/my-app', - versions: ['0.0.0-my-branch-46'], - }) - ) - ); + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists(); await anApp().deleteArtifact(version); @@ -165,20 +181,80 @@ describe('NxProject.deleteArtifact', () => { expect(options.cwd).toContain('dist/apps/my-app'.replace(/\//g, sep)); }); - test('unpublishes the version when the registry has it', async () => { - (execSync as jest.Mock).mockReturnValue( - Buffer.from( - JSON.stringify({ - name: '@cplace-next/my-app', - versions: ['0.0.0-my-branch-46'], - }) - ) + test('unpublishes the version when the registry has it, then checks it is gone', async () => { + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists('0.0.0-my-branch-45'); + + await anApp().deleteArtifact(version); + + expect(commands()).toEqual([SHOW, UNPUBLISH, SHOW]); + expect(NxProject.sleep).not.toHaveBeenCalled(); + }); + + test('a version still listed right after the unpublish is checked again before it counts', async () => { + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists('0.0.0-my-branch-46'); // metadata not recalculated yet + npmShowLists(); + + await anApp().deleteArtifact(version); + + expect(commands()).toEqual([SHOW, UNPUBLISH, SHOW, SHOW]); + expect(NxProject.sleep).toHaveBeenCalledWith( + NxProject.UNPUBLISH_RECHECK_DELAY_MS ); + }); + + test('an unpublish that reported success but deleted nothing is retried', async () => { + const exit = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists('0.0.0-my-branch-46'); + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists(); await anApp().deleteArtifact(version); - expect((execSync as jest.Mock).mock.calls[1][0]).toBe( - 'npm unpublish @cplace-next/my-app@0.0.0-my-branch-46 --force' + expect(commands()).toEqual([SHOW, UNPUBLISH, SHOW, SHOW, UNPUBLISH, SHOW]); + expect(console.warn).toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); + + test('fails the run when the version survives two unpublish attempts', async () => { + const exit = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists('0.0.0-my-branch-46'); + npmShowLists('0.0.0-my-branch-46'); + npmUnpublishReportsSuccess(); + npmShowLists('0.0.0-my-branch-46'); + npmShowLists('0.0.0-my-branch-46'); + + await anApp().deleteArtifact(version); + + expect(commands()).toEqual([ + SHOW, + UNPUBLISH, + SHOW, + SHOW, + UNPUBLISH, + SHOW, + SHOW, + ]); + expect(exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + 'still in the registry after two npm unpublish attempts' + ) ); }); diff --git a/tools/scripts/artifacts/nx-project.ts b/tools/scripts/artifacts/nx-project.ts index 99968f7b..9c2abaac 100644 --- a/tools/scripts/artifacts/nx-project.ts +++ b/tools/scripts/artifacts/nx-project.ts @@ -34,6 +34,7 @@ export class NxProject { public static readonly PACKAGEJSON = 'package.json'; public static readonly FOSS_LIST_FILENAME = 'cplace-foss-list.json'; public static readonly PENDING_TAG_SUFFIX = '-pending'; + public static readonly UNPUBLISH_RECHECK_DELAY_MS = 5000; private _npmrcContent = ''; private _packageJsonContent: any = {}; public isPublishable: boolean = false; @@ -269,18 +270,25 @@ export class NxProject { `Package ${scopedPackage}@${version.toString()} exists in registry` ); console.log( - `About to delete artifact from Jfrog: ${this.name}@${version.toString()}` - ); - console.log( - execSync( - `npm unpublish ${this.scope}/${ - this.name - }@${version.toString()} --force`, - { - cwd: `${this.getPathToProjectInDist()}`, - } - ).toString() + `About to delete artifact from Jfrog: ${ + this.name + }@${version.toString()}` ); + this.unpublish(version); + if (await this.isStillPublished(scopedPackage, version)) { + console.warn( + `${scopedPackage}@${version.toString()} is still in the registry after npm unpublish reported success, retrying` + ); + this.unpublish(version); + if (await this.isStillPublished(scopedPackage, version)) { + console.error( + `${scopedPackage}@${version.toString()} is still in the registry after two npm unpublish attempts. ` + + `Unpublish it by hand before the next publish of this version.` + ); + process.exit(1); + return; + } + } console.log( `Deleted artifact from Jfrog: ${this.name}@${version.toString()}` ); @@ -290,6 +298,38 @@ export class NxProject { } } + private unpublish(version: Version) { + console.log( + execSync( + `npm unpublish ${this.scope}/${ + this.name + }@${version.toString()} --force`, + { + cwd: `${this.getPathToProjectInDist()}`, + } + ).toString() + ); + } + + /** + * npm unpublish can report success and delete nothing (live test A9: the + * version stayed in JFrog), so the deletion is checked. The registry metadata + * may still list a just deleted version, so a hit is checked once more after + * a delay before it counts. + */ + private async isStillPublished( + scopedPackage: string, + version: Version + ): Promise { + if (!this.packageExists(scopedPackage, version.toString())) return false; + await NxProject.sleep(NxProject.UNPUBLISH_RECHECK_DELAY_MS); + return this.packageExists(scopedPackage, version.toString()); + } + + public static sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + public writeNPMRCInDist(jfrogCredentials: JfrogCredentials, scope: string) { this.npmrcContent = `${scope}:registry=${jfrogCredentials.url} \n`; this.npmrcContent = From 243dbdfc6e0128c3d7104d085aa8eb5e30c8fde1 Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Tue, 3 Nov 2026 12:08:30 +0100 Subject: [PATCH 09/11] PFM-ISSUE-34283 - Record live tests A10 and A11, fix the A10 commands Co-Authored-By: Claude Opus 5.5 --- .../clone-cplace-fe-live-tests.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 49d755e1..126b6112 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -518,13 +518,15 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. ### A10: `github-release` re-run (recovery path) - **Purpose:** check the README recovery step "Re-run failed jobs" for a failed `github-release`, and that the step is idempotent. -- **Precondition:** a successful RELEASE run `RUN` with tag `version/N` and a release (for example A4's run). +- **Precondition:** the **most recent** successful RELEASE run `RUN`, with tag `version/N` and a release that is the current latest. A re-run reuses the `release` job's outputs, `is_latest` included: re-running an older run would mark an older version as latest. - **Steps:** - 1. Run `gh run rerun $RUN -R $R --job `. + 1. Run `gh run rerun -R $R --job `. `gh` 2.29 rejects a run id together with `--job`. Each re-run is a new attempt with a new job id: read it from that attempt's `jobs.json`. - **Expected:** `success`, log `A GitHub release for version/N already exists, nothing to do`, and `releases.txt` unchanged. - 2. Delete only the release: `gh release delete version/N -R $R -y`. Keep the tag. Re-run the same job again. + 2. Delete only the release: `gh api -X DELETE repos/$R/releases/`, and check for a 404 afterwards (`gh release delete` panicked once in the Windows keyring and deleted nothing). Keep the tag. Re-run the job again. - **Expected:** `success`. The release `version/N` is recreated: author `github-actions[bot]`, same compare range as before, `latest` as before. - The release job is **not** re-run, so no new publish happens and E4 is unchanged. +- **Note:** a re-run uses the reusable workflow at the SHA of the original run (job log: `Uses: …fe-release-flow.yml@refs/heads/ ()`), not the current tip of the test branch. To test a fix pushed to the test branch, start a new run. +- **Result (2026-11-03):** PASS on A9's run `41504160011` (attempts 4 and 6). The recreated release has a new id, the same body, and is latest again. ### A11: Burst of three pushes (concurrency, affected base) @@ -548,6 +550,8 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. - If cf-editor-lib-e2e or other projects are affected: RELEASE(N+1), tag on **SHA3**, and the GitHub release range `version/N...version/N+1` covers SHA2 and SHA3. Otherwise NO-RELEASE, and the log's affected list explains why. - No run is in progress twice in the group at any time. - **Evidence:** the three `run.json` files (`status`, `conclusion`, `run_started_at`, `updated_at`) and `jobs.tsv`. +- **Note:** an e2e app counts as affected only with a `public_api.ts` (only those are published). Replay #3 changes only `cf-editor-lib-e2e` visual baselines, and that app has none, so run 3 is expected to be NO-RELEASE. +- **Result (2026-11-03):** PASS. Run 1 `41619940413` released `26.4.6` on SHA1. Run 2 `41619995568` was cancelled while pending with 0 jobs. Run 3 `41620040207` started 2 s after run 1 completed and logged `Checking affected projects since version/26.4.6`, then NO-RELEASE. The working copy pushes the prepared SHAs (`git push origin :refs/heads/$BR`), all three cherry-picked before the first push. ### A12 (optional): Older line, not latest From 53d3a7f33e9bfea5764987d957e7034b38001e5f Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Tue, 3 Nov 2026 13:05:19 +0100 Subject: [PATCH 10/11] PFM-ISSUE-34283 - Record live test A12, skip A13 Co-Authored-By: Claude Opus 5.5 --- .../clone-cplace-fe-live-tests.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index 126b6112..c4ed465b 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -561,10 +561,13 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. - E6 with `is_latest=false`: `latest-release.txt` is unchanged and still the newest `26.4.x`. - `release-26.4` is unchanged. - **Caution:** run this after all 26.4 cases. It leaves a second line, and later teardown must remove it. +- **Driver:** the pushed SHA already has push runs from earlier cases, so find the run with `created>=T0` as well as `head_sha`. +- **Result (2026-11-03):** PASS, run `41626252350`. The release notes range is `version/26.4.6...version/26.3.1`: the first tag of a line has no `previous_tag`, so GitHub picks the previous release (the latest) itself. That is correct for real new lines, which are always newer; only this artificial older line gives a backwards range. ### A13 (optional): Forced dispatch on `main` (snapshot path) -- **Precondition:** S3 has been done. +- **Precondition:** S3 has been done. Before S3, check the other workflows on `main` (`push_main.yaml`, `deploy_storybook.yaml`, `sonar-scan.yml`, `xray-scan.yml`, `auto-pr-upmerge.yml`): the push to `main` triggers them, and any that reach an external system must be disabled in the same commit. +- **Status (2026-11-03):** skipped by decision. The snapshot path is covered by unit tests. - **Trigger:** `gh workflow run fe-release.yml -R $R --ref main -f force=true` - **Expected:** - `release` runs and logs `Forced release flow on main: publishing a snapshot of all projects`. From 1b90347507d93811b8834a57e4497a8de74636d9 Mon Sep 17 00:00:00 2001 From: Vladimir Arsov Date: Tue, 3 Nov 2026 13:29:02 +0100 Subject: [PATCH 11/11] PFM-ISSUE-34283 - Make the live-test teardown optional Co-Authored-By: Claude Opus 5.5 --- .../clone-cplace-fe-live-tests.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md index c4ed465b..3391eda8 100644 --- a/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md +++ b/specs/2026-10-12_PFM-ISSUE-34283_consolidate-fe-release-workflow/clone-cplace-fe-live-tests.md @@ -308,7 +308,7 @@ wait_run() { until [ "$(gh api repos/$R/actions/runs/$1 --jq .status)" = complet 4. Wait for the canary or any tag-triggered runs to settle (poll `runs_since "$T0"` until nothing is `in_progress`/`queued` for 60 s). Then `snapshot "$OUT//after"`. 5. Registry metadata is recalculated **asynchronously**. If `dist-tags.tsv` lags the tarballs, re-snapshot up to 3 times, 2 minutes apart, before calling a dist-tag row failed. 6. Evaluate every row of the case against the rules in 3.1. Write `$OUT//verdict.md` with the comparison table (row, expected, actual, PASS/FAIL, evidence file). -7. Append what the case created (tags, releases, npm versions, branches) to `$OUT/ledger.tsv`. Teardown (Part 4) reads it. +7. Append what the case created (tags, releases, npm versions, branches) to `$OUT/ledger.tsv`. An optional teardown (Part 4) reads it. 8. Append one line to `$OUT/results.md`: `| | | PASS/FAIL | |`. ## 2.4 Preflight (read-only, before Phase A and again after S2) @@ -490,11 +490,11 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. - S1: no `version/N` tag and no release. The AQL for `*-N.tgz` under `@cplace-next` returns **0**. - Dist-tags: `release-26.4` is unchanged from S0 on **every** package, still `PREV`, and no `release-26.4-pending` is left. The log has no `Promote phase` line. - First run, before the staging fix (run `41480323282`): 26 of 27 packages lost `release-26.4` entirely. - - Placeholder side effect: `jf rt u` moves the package's `latest` to the placeholder version; Artifactory moves it to the highest remaining version after the delete. Compare `latest` of `LAST` with the snapshot taken **before** the upload, and restore it in teardown. + - Placeholder side effect: `jf rt u` moves the package's `latest` to the placeholder version; Artifactory moves it to the highest remaining version after the delete. Compare `latest` of `LAST` with the snapshot taken **before** the upload, and restore it in teardown (if one is run). - A `Deleted artifact` line missing for a published package, or a JFrog 403 on delete, is a FAIL: the JFrog user lacks delete rights. Record it. - **Trigger 2:** capture attempt 1, then `gh run rerun $RUN -R $R --failed`. - **Expected, attempt 2:** RELEASE(N), E1–E8 (E1 for attempt 2). The same `N` as attempt 1, not `N+1`. -- **If attempt 1 leaves residue** (tarballs at `N`): stop. Teardown must delete them (4.2) before anything else. +- **If attempt 1 leaves residue** (tarballs at `N`): stop. Delete them (4.2) before anything else. ### A9: Cancel during publish; cleanup; re-run @@ -560,7 +560,7 @@ Version numbers in the cases assume the order above starting from S0 = {26.4.1}. - RELEASE(26.3.1): all publishable projects, dist-tag `release-26.3`. - E6 with `is_latest=false`: `latest-release.txt` is unchanged and still the newest `26.4.x`. - `release-26.4` is unchanged. -- **Caution:** run this after all 26.4 cases. It leaves a second line, and later teardown must remove it. +- **Caution:** run this after all 26.4 cases. It leaves a second line, which stays unless an (optional) teardown removes it. - **Driver:** the pushed SHA already has push runs from earlier cases, so find the run with `created>=T0` as well as `head_sha`. - **Result (2026-11-03):** PASS, run `41626252350`. The release notes range is `version/26.4.6...version/26.3.1`: the first tag of a line has no `previous_tag`, so GitHub picks the previous release (the latest) itself. That is correct for real new lines, which are always newer; only this artificial older line gives a backwards range. @@ -671,9 +671,11 @@ Throwaway branches (`test/fe-release-dispatch`, `release/99.9`, `test/ceiling`) Each npm version is a tarball `cplace-npm-test/@cplace-next/

/-/@cplace-next/

-.tgz`. If present, it also has a metadata JSON under `cplace-npm-test/.npm/@cplace-next/

/`. Search both paths, as in the backend procedure's npm cleanup. -## 4.3 Teardown (end of the series, stop before each step) +## 4.3 Teardown (optional, end of the series, stop before each step) -1. **Decide with the user** what stays. By default the series' versions and tags can stay, because `clone-cplace-fe` and `cplace-npm-test` are test-only. Only residue from failed cases must go. +**Optional.** `clone-cplace-fe` and `cplace-npm-test` are test-only, so the series' tags, releases, branches and versions can stay. After the 2026-11-02/03 series nothing was torn down, by decision. Run these steps only when asked, for example to make the replay queue reusable. Residue that blocks a later case (a tarball at the next `N`) is removed inside that case (A8, A9), not here. + +1. **Decide with the user** what goes. 2. **Tags and releases** in the ledger: `gh release delete -R $R -y`, then `git push origin :refs/tags/`. 3. **npm versions** in the ledger: follow 4.2. 4. **Branches.**