Skip to content

ci: never cancel main runs, and make publish concurrency-safe - #93

Open
shaharkazaz wants to merge 2 commits into
mainfrom
ci/no-cancel-main-runs
Open

ci: never cancel main runs, and make publish concurrency-safe#93
shaharkazaz wants to merge 2 commits into
mainfrom
ci/no-cancel-main-runs

Conversation

@shaharkazaz

@shaharkazaz shaharkazaz commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two related problems, one caused by fixing the other.

1. Runs on main were cancelled. The workflow used a single concurrency group per
ref with cancellation enabled, so every push to main produced the same group key
(CI-refs/heads/main) and commit N+1 cancelled the still-running job for commit N.
Back-to-back merges left main with no full verification, and could abort the
publish job partway through a release.

2. Removing that cancellation exposes a publish race. cancel-in-progress was
quietly doing double duty: as a CI cost saver and as a de-facto debounce on the
release pipeline. Only the newest commit ever reached npm publish, so the
auto-versioning logic never had to be concurrency-safe. Without cancellation, two
merges landing together would both publish:

Run A Run B
version source own package.json (2.0.1) own package.json (2.0.1)
computed version 2.0.2 2.0.2
npm view guard not published yet → proceed not published yet → proceed
outcome publishes 2.0.2, pushes tag fails — 403 over-publish or rejected push

The npm view check is a TOCTOU test, not a lock. Worse than the red job: 2.0.2 is
built from A's tree, and B's own changes may never get published at all.

Solution

Verification: per-commit groups (commit 1)

Append the commit sha to the concurrency group only for refs/heads/main:

group: ${{ github.workflow }}-${{ github.ref }}${{ github.ref == 'refs/heads/main' && format('-{0}', github.sha) || '' }}
Event Group
push main @ abc123 CI-refs/heads/main-abc123
push main @ def456 CI-refs/heads/main-def456
PR #42 CI-refs/pull/42/merge

Each main commit is alone in its group, so it is never cancelled and never queued
behind another — the full matrix runs for every commit. PR runs keep the existing
per-ref group, so pushing to a PR branch still cancels the superseded run.

Note that cancel-in-progress: false on a shared group would not work here: it
queues the newer run rather than running it, so pushes to main would serialize
behind the whole build matrix.

Release: compare-and-swap (commit 2)

All inside the publish job:

  1. Queue, don't overlap — job-level concurrency: { group: publish-main, cancel-in-progress: false }.
  2. Version from the last tag, not package.json — re-fetch tags first, since a
    queued run's checkout predates the tag an earlier run pushed while it waited.
  3. Claim the version before publishing — push the tag on its own. Creating a
    remote ref is atomic and reversible, so the run that wins owns the version;
    losers exit 0 before npm publish, the only irreversible step.
  4. Publish, then push the version commit, rebasing onto main if a feature
    commit landed meanwhile.

The npm view guard is retained, so a retry after a flaky publish stays idempotent:
re-pushing the same tag from the same commit is a no-op success, which lets a re-run
proceed rather than skip.

Deviation from review feedback

The review asked for git push --atomic origin main v<ver> (commit and tag)
before publishing. That is not implementable here — the release has an ordering cycle:

npm publish → platform packages exist on the registry → yarn install can resolve
them → yarn.lock is valid → the version commit passes --frozen-lockfile in its
own CI run

scripts/generate-packages.js repoints root optionalDependencies at
@front-ops/domino-<platform>@<new version>, and prepublishOnly
(napi prepublish) is what publishes those platform packages. So a version commit
pushed before npm publish carries a yarn.lock that cannot resolve, and the
bot's own CI run on that commit goes red. (This is what the existing retry loop
commented "handle npm registry propagation delay after publish" is about.)

The atomic push is therefore split: the tag is the CAS and goes first, the commit
follows the publish. This keeps the property the feedback is actually after — a
reversible claim ahead of any irreversible side effect.

Consequence to be aware of: the release tag now points at the commit that was
released, rather than at the version-bump commit that follows it. git describe and
bump derivation are unaffected (the tag is still an ancestor). A stale tag from a run
that died before publishing is self-healing: the next run simply bumps past it.

Key Changes

  • .github/workflows/CI.yml
    • sha-suffixed workflow concurrency group for main
    • publish: job concurrency group, tag re-fetch, version derived from last tag,
      new Claim version CAS step, side-effecting steps gated on the claim, and a
      rebase-retry on the version commit push

Testing

  • Workflow YAML parses; all 8 jobs resolve; prettier clean.
  • Version derivation snippet extracted from the workflow and executed:
    v2.0.1+patch→2.0.2, +minor→2.1.0, +major→3.0.0, tagless→package.json+1.
  • CAS verified against local repos: second claim of the same version from a different
    sha is rejected (already exists)exit 0 with no npm side effect; re-claim from
    the same sha is Everything up-to-date → the idempotent retry path.
  • Rebase-retry verified on realistic history (version commit replays cleanly over a
    feature commit that landed during the publish; both changes survive).
  • Step gating verified: with is_release=true the claim step is skipped, so claimed
    is empty and every side-effecting step skips.
  • No Rust or JS sources touched, so the cargo/yarn suites are unaffected.
  • Not exercised end-to-end against the real npm registry — the first real release
    after merge is the remaining verification.

Out of Scope

  • pages.yml — docs deploy where only the newest build matters, cancellation is correct.
  • preview-release.yml — gated to workflow_run.event == 'pull_request', never runs
    for main pushes.
  • Merge queue — a reasonable follow-up for bulk-merge correctness, not needed for this fix.

Summary by CodeRabbit

  • Bug Fixes
    • Improved release workflow reliability when multiple builds run concurrently.
    • Prevented active main-branch releases from being canceled unexpectedly.
    • Reduced versioning conflicts and ensured packages are published only after a release is successfully claimed.
    • Added safer handling for synchronizing release changes with the latest main branch.

Pushes to main shared a single concurrency group with cancel-in-progress
enabled, so each new commit cancelled the run for the previous one --
including the push-only publish job.

Append the commit sha to the concurrency group for refs/heads/main, giving
every commit on main its own group. Nothing cancels it and nothing queues
behind it, so each commit runs the full matrix. Pull request runs keep the
existing per-ref group, so superseded PR runs are still cancelled.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The CI workflow now preserves concurrent main runs, serializes publishing, recalculates versions from refreshed tags, atomically claims release tags, gates npm publication, and retries pushing version commits after rebasing.

Changes

Release publishing concurrency

Layer / File(s) Summary
Run and publish serialization
.github/workflows/CI.yml
Main-branch concurrency groups include commit SHAs, while the publish job uses a non-canceling fixed concurrency group.
Version refresh, claim, and publish
.github/workflows/CI.yml
Version calculation uses refreshed tags and the last tagged version; package generation and npm publishing require a successful atomic tag claim.
Version commit retry
.github/workflows/CI.yml
The version commit push is separated from tag claiming and retries after fetching and rebasing onto the latest main commit.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant OriginMain
  participant GitTags
  participant Npm
  GitHubActions->>OriginMain: Force-fetch main and tags
  GitHubActions->>GitTags: Atomically push version tag
  GitTags-->>GitHubActions: Return claim result
  GitHubActions->>Npm: Generate and publish packages when claimed
  GitHubActions->>OriginMain: Push version commit
  OriginMain-->>GitHubActions: Reject or accept push
  GitHubActions->>OriginMain: Re-fetch and rebase after rejection
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: protecting main CI runs from cancellation and making publish jobs concurrency-safe.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/no-cancel-main-runs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📦 Preview Release Available

A preview release has been published for commit ee309e0.

Installation

npm install https://github.com/frontops-dev/domino/releases/download/pr-93-ee309e0/front-ops-domino-2.0.2.tgz

Running the preview

npx https://github.com/frontops-dev/domino/releases/download/pr-93-ee309e0/front-ops-domino-2.0.2.tgz affected

Details

Removing cancellation on main means several publish jobs can be in flight,
and they previously all derived the next version from their own package.json.
Two merges landing together would compute the same version, and the loser
would fail after the winner had already published to npm.

- Queue publishes (job concurrency group, cancel-in-progress: false) so they
  never overlap.
- Re-fetch tags and derive the next version from the last tag rather than from
  package.json, since a queued run's checkout predates the version commit an
  earlier run pushed.
- Claim the version by pushing the tag on its own before publishing. Creating a
  remote ref is atomic and reversible, so the winner owns the version and the
  losers exit 0 before npm publish, the only irreversible step. Re-pushing the
  same ref from the same commit is a no-op, so retrying a flaky publish works.
- Push the version commit after publishing, rebasing onto main if a feature
  commit landed meanwhile.

The version commit cannot be pushed before npm publish: generate-packages.js
points optionalDependencies at the new platform package versions, so the
yarn.lock refresh in that commit can only resolve once those are on the
registry. Splitting the old atomic commit+tag push is what keeps the claim
ahead of the publish.
@shaharkazaz shaharkazaz changed the title ci: never cancel CI runs for pushes to main ci: never cancel main runs, and make publish concurrency-safe Jul 28, 2026
@shaharkazaz

Copy link
Copy Markdown
Collaborator Author

All three review items are in — the PR is now two commits.

What went in

Item 3 (keep per-commit no-cancel): unchanged, CI.yml:26.

Item 2 (the real fix), all inside publish:

  • concurrency: { group: publish-main, cancel-in-progress: false }
  • re-fetch tags, derive the next version from the last tag rather than package.json
  • new Claim version step: push the tag alone as a CAS; on reject → exit 0
  • every side-effecting step (Generate packages, List packages, Publish to npm, push) gated on steps.claim.outputs.claimed == 'true'
  • version commit pushed after publish, with a bounded rebase-retry

One deviation, and why

The feedback asked for git push --atomic origin main v<ver> — commit and tag — before publishing. That one isn't implementable here; the release has an ordering cycle:

npm publish → platform packages exist on registry → yarn install resolves them → yarn.lock valid → version commit passes --frozen-lockfile in its own CI run

scripts/generate-packages.js (L170-175) repoints root optionalDependencies at @front-ops/domino-<platform>@<new version>, and prepublishOnly: napi prepublish is what publishes those platform packages. So a version commit pushed before npm publish carries a yarn.lock that cannot resolve, and the bot's own CI run on that commit goes red. That is what the pre-existing retry loop commented "handle npm registry propagation delay after publish" is about.

So the atomic push is split: the tag is the CAS and goes first; the commit follows the publish. This preserves the property the feedback is actually after — a reversible claim ahead of any irreversible side effect.

Consequence worth flagging: the release tag now points at the commit that was released, rather than at the version-bump commit that follows it. git describe and bump derivation are unaffected (the tag is still an ancestor), and a stale tag from a run that died pre-publish is self-healing — the next run just bumps past it. Keeping tags on the bump commit instead would need a separate claim ref, which trades that self-healing for a stall requiring manual cleanup.

Verified by execution, not inspection

  • Version derivation snippet extracted from the workflow file and executed: v2.0.1+patch→2.0.2, minor→2.1.0, major→3.0.0, the no-v tag form works, and tagless falls back to package.json+1.
  • CAS against real local repos: a second claim of the same version from a different sha is rejected (already exists) → exit 1 → our else-branch exit 0, no npm side effect. Same sha → Everything up-to-date → the idempotent retry path.
  • Rebase-retry on realistic history: the version commit replays cleanly over a feature commit that landed mid-publish, and both changes survive. (First fixture for this was built wrong — it forced a conflict on a commit that is already an ancestor of main in reality — so it was rebuilt.)
  • Workflow YAML parses, all 8 jobs resolve, prettier clean.
  • Step gating: with is_release=true the claim step is skipped, so claimed is empty and every side-effecting step skips.

Not verified: an end-to-end release against the real npm registry. The first merge after this lands is the remaining check.

One behavior to decide on

GitHub's documented rule for a shared concurrency group is that only one run may be pending — a newly queued run cancels the previously pending one. So with three merges in rapid succession, the middle commit's publish job can be cancelled while pending.

Nothing is lost when that happens (its CAS would have skipped anyway, and no verification job is ever cancelled), but the run shows a cancelled job rather than a green skip — which superficially resembles the symptom this PR set out to fix. Dropping the publish-main group would make those losers exit green instead, since the CAS is the authoritative guard; the group was requested for efficiency, so it is kept for now. Easy to drop if green skips read better.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.github/workflows/CI.yml (1)

492-492: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Route computed values through env: instead of interpolating ${{ }} directly into run:.

Static analysis flags direct template expansion into shell at these three new lines (npm version ${{ ... }}, TAG="v${{ ... }}", and the ::error::v${{ ... }} message). The values are internally computed semver strings today, so exploitability is low, but this is the standard GitHub Actions hardening pattern to eliminate any script-injection surface, and the fix is trivial. Note line 553 (git commit -m "${{ steps.bump.outputs.new_version }}", pre-existing/unchanged) has the same pattern.

🔧 Suggested fix
       - name: Bump version
         id: bump
         if: steps.version.outputs.is_release != 'true'
+        env:
+          NEXT_VERSION: ${{ steps.version.outputs.next_version }}
         run: |
-          npm version ${{ steps.version.outputs.next_version }} --no-git-tag-version --ignore-scripts --allow-same-version
+          npm version "$NEXT_VERSION" --no-git-tag-version --ignore-scripts --allow-same-version
           node scripts/sync-cargo-version.js
           NEW_VERSION=$(node -p "require('./package.json').version")
           echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
           echo "Bumped to $NEW_VERSION"
       - name: Claim version
         id: claim
         if: steps.version.outputs.is_release != 'true'
+        env:
+          NEW_VERSION: ${{ steps.bump.outputs.new_version }}
         run: |
-          TAG="v${{ steps.bump.outputs.new_version }}"
+          TAG="v${NEW_VERSION}"

Also applies to: 501-501, 563-563

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/CI.yml at line 492, Replace direct GitHub Actions
expression interpolation in the new npm version, TAG assignment, and
error-message run commands with environment-variable references, and define
those values through each step’s env configuration. Preserve the existing
computed semver values and command behavior while avoiding `${{ }}` expansion
inside shell scripts; do not modify the pre-existing commit command.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.github/workflows/CI.yml:
- Line 492: Replace direct GitHub Actions expression interpolation in the new
npm version, TAG assignment, and error-message run commands with
environment-variable references, and define those values through each step’s env
configuration. Preserve the existing computed semver values and command behavior
while avoiding `${{ }}` expansion inside shell scripts; do not modify the
pre-existing commit command.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8ab92e2f-1741-44aa-bc77-2555273b40d9

📥 Commits

Reviewing files that changed from the base of the PR and between e824aac and ee309e0.

📒 Files selected for processing (1)
  • .github/workflows/CI.yml

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants