Skip to content

runVersion resets to origin/${branch} instead of pipeline commit SHA, causing race conditions with concurrent CI pipelines #236

@aui

Description

@aui

Summary

When runVersion prepares the version branch, it runs:

await exec('git', ['fetch', 'origin', currentBranch])
await gitUtils.reset(`origin/${currentBranch}`)

This resets the working tree to the latest tip of the remote branch, not the commit that triggered the pipeline (CI_COMMIT_SHA).

The official changesets/action behaves differently: it resets to github.context.sha (the workflow trigger commit) in prepareBranch():

// changesets/action/src/git.ts
async prepareBranch(branch: string) {
  await switchToMaybeExistingBranch(branch, { cwd: this.cwd });
  await reset(github.context.sha, { cwd: this.cwd });
}

GitHub Action also documents this intent in run.ts:

@changesets/ghcommit has to reset the branch to the same commit as the base, which GitHub will then react to by closing the PRs

We could not find any comment, README note, or issue explaining why changesets-gitlab intentionally diverges from this behavior.

Impact

In environments where multiple pipelines on the default branch can run concurrently (e.g. auto_cancel.on_new_commit: none), an older pipeline whose changeset job runs late may:

  1. Decide to create/update a version MR based on changesets found at CI_COMMIT_SHA (decision in main() is correct).
  2. Then execute runVersion, which fetch + reset to latest origin/main — potentially a different commit than the one that triggered the pipeline.
  3. Run changeset version against that newer state and force-push an updated changeset-release/* branch / version MR.

This can interfere with the normal release flow:

  • Feature MR merges → version MR created
  • Version MR merges → next pipeline should publish (no changesets on that commit)
  • Meanwhile, a delayed job from an earlier pipeline may still run runVersion against the latest main and create/update another version MR

We observed this in a monorepo with high merge frequency on the default branch. As a mitigation we added resource_group to serialize changeset jobs, but the underlying reset behavior still seems incorrect compared to the GitHub Action.

Expected behavior

runVersion should reset the version branch to the pipeline trigger commit, equivalent to GitLab's CI_COMMIT_SHA:

await gitUtils.switchToMaybeExistingBranch(versionBranch)
await gitUtils.reset(process.env.CI_COMMIT_SHA!)

Or, if a fetch is still needed for shallow clones:

await exec('git', ['fetch', 'origin', currentBranch])
await gitUtils.reset(process.env.CI_COMMIT_SHA!)

This would align with changesets/action and ensure each pipeline only versions changesets present on its own trigger commit.

Actual behavior

runVersion always resets to origin/${currentBranch} after fetch, i.e. the current remote branch tip, which may be ahead of CI_COMMIT_SHA.

Relevant code (src/run.ts):

const currentBranch = context.ref  // CI_COMMIT_REF_NAME
// ...
await gitUtils.switchToMaybeExistingBranch(versionBranch)
await exec('git', ['fetch', 'origin', currentBranch])
await gitUtils.reset(`origin/${currentBranch}`)

Note: the publish vs. version MR decision in main() reads .changeset/ from the CI workspace at job start (before fetch/reset), so that part is fine. The problem is specifically in runVersion execution, after the decision has already been made.

Steps to reproduce

  1. Configure CI on the default branch with multiple concurrent pipelines allowed (do not auto-cancel older pipelines on new commits).
  2. Push commit A to main with a changeset → pipeline P1 starts.
  3. Let P1 run through early stages slowly (or delay the changeset job via queue/resource_group without serialization).
  4. Merge a version MR (commit B, no changesets) → pipeline P2 should publish.
  5. Push commit C with a new changeset → pipeline P3 starts.
  6. When P1's delayed changeset job finally runs:
    • readChangesetState() on checkout A → has changesets → enters runVersion
    • git fetch + reset origin/main → workspace moves to C
    • Creates/updates version MR based on C, not A

Environment

  • changesets-gitlab: 0.12.2 (also present on main at time of writing)
  • GitLab CI, default branch push pipelines
  • INPUT_PUBLISH configured (publish after version MR merge)
  • INPUT_REMOVE_SOURCE_BRANCH: true

Suggested fix

  1. Change runVersion to reset to CI_COMMIT_SHA instead of origin/${currentBranch}.
  2. When INPUT_TARGET_BRANCH / mrTargetBranch differs from currentBranch, reset to the appropriate base commit for the target branch (currently fetch/reset uses currentBranch even though MR target may be different — see Create MR with a custom target branch #69 / feat: create MR with custom target branch #70).

Workaround

We added resource_group: changeset on our GitLab CI job to serialize changeset executions per project. This reduces the race but does not fix the root cause, and does not help with merge races where the version MR merge commit itself still contains new changesets from concurrent merges.

References

Happy to provide a PR if this analysis looks correct.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions