Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .github/workflows/back-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Opens an automated PR back-merging `main` into `dev` after every release, so
# the prerelease cycle reopens from the freshly published stable version.
#
# It cannot trigger on `push` to main: the release commit Lerna creates is
# `[no ci] Release: x.y.z`, which makes GitHub skip push-triggered workflows.
# Instead it runs via `workflow_run` once the CD workflow finishes on `main`.
#
# Deterministic conflicts (version fields, CHANGELOGs) are resolved by
# scripts/back-merge-resolve.mjs; generated files and the lockfile are
# regenerated. Any leftover (non-deterministic) conflict is auto-resolved to the
# dev side and the PR is opened as a draft flagged for manual review, so a human
# confirms no `main` hotfix was dropped before merging.
name: Back-merge main to dev

on:
workflow_run:
workflows: ["CD"]
types: [completed]

permissions:
contents: write
pull-requests: write

concurrency:
group: back-merge-main-to-dev
cancel-in-progress: false

jobs:
back-merge:
name: Back-merge
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
with:
token: ${{ secrets.VTEX_GITHUB_BOT_TOKEN }}
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.0

- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 24.13.0
cache: "pnpm"

- name: Configure CI Git User
run: |
git config user.name vtexgithubbot
git config user.email vtexgithubbot@github.com

- name: Prepare sync branch
id: prep
run: |
git fetch origin main dev --tags
VERSION=$(node -p "require('./lerna.json').version")
BRANCH="chore/sync-dev-with-main-$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
if git merge-base --is-ancestor origin/main origin/dev; then
echo "main is already contained in dev; nothing to back-merge."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
git checkout -B "$BRANCH" origin/dev
fi

- name: Merge main and resolve
if: steps.prep.outputs.skip == 'false'
id: resolve
run: |
# Let the merge report conflicts without aborting the step.
git merge --no-ff origin/main \
-m "chore: back-merge main into dev (v${{ steps.prep.outputs.version }})" || true

# Deterministic conflicts: version fields + CHANGELOGs.
node ./scripts/back-merge-resolve.mjs

# Whatever remains is either regenerated (lockfile / generated code)
# or needs a human. Keep the dev side to clear markers, and flag the
# non-regenerable ones for review.
DRAFT=false
FLAGGED=""
for f in $(git diff --name-only --diff-filter=U); do
case "$f" in
pnpm-lock.yaml|*/@generated/*|*/__generated__/*) ;;
*) DRAFT=true; FLAGGED="${FLAGGED}- \`${f}\`"$'\n' ;;
esac
git checkout --ours -- "$f"
git add -- "$f"
done
echo "draft=$DRAFT" >> "$GITHUB_OUTPUT"
{
echo "flagged<<EOF"
printf '%s' "$FLAGGED"
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Regenerate lockfile and codegen
if: steps.prep.outputs.skip == 'false'
run: |
pnpm i
pnpm --filter @faststore/api build
pnpm --filter @faststore/api generate
pnpm --filter @faststore/core generate
git add -A

- name: Commit and push
if: steps.prep.outputs.skip == 'false'
run: |
if [ -f .git/MERGE_HEAD ]; then
git commit --no-edit
elif ! git diff --cached --quiet; then
git commit -m "chore: regenerate lockfile and codegen after back-merge"
fi
git push --force-with-lease origin "${{ steps.prep.outputs.branch }}"

- name: Open pull request
if: steps.prep.outputs.skip == 'false'
env:
GH_TOKEN: ${{ secrets.VTEX_GITHUB_BOT_TOKEN }}
VERSION: ${{ steps.prep.outputs.version }}
BRANCH: ${{ steps.prep.outputs.branch }}
DRAFT: ${{ steps.resolve.outputs.draft }}
FLAGGED: ${{ steps.resolve.outputs.flagged }}
run: |
EXISTING=$(gh pr list --head "$BRANCH" --base dev --state open --json number --jq '.[0].number' || true)
if [ -n "$EXISTING" ]; then
echo "PR already open for $BRANCH: #$EXISTING"
exit 0
fi

# Build the body via printf + env vars (never shell-interpolate the
# flagged file list — it contains backticks).
BODY_FILE="$RUNNER_TEMP/pr-body.md"
{
printf 'Automated back-merge of `main` (v%s) into `dev`, to reopen the prerelease cycle from the freshly published stable version.\n\n' "$VERSION"
printf 'Deterministic conflicts (version fields and CHANGELOGs) were resolved automatically; the lockfile and generated files were regenerated.\n'
if [ "$DRAFT" = "true" ]; then
printf '\n> :warning: **Needs manual review.** Non-deterministic conflicts were auto-resolved by keeping the `dev` side in the files below. Verify no `main` hotfix was dropped before merging:\n\n%s\n' "$FLAGGED"
fi
} > "$BODY_FILE"

DRAFT_FLAG=""
if [ "$DRAFT" = "true" ]; then DRAFT_FLAG="--draft"; fi

gh pr create \
--base dev \
--head "$BRANCH" \
--title "chore: update dev with main (v$VERSION)" \
--body-file "$BODY_FILE" $DRAFT_FLAG
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,24 @@ jobs:
run: |
cd starter
yarn build

# Informational check (never blocks): flags FastStore packages whose latest
# published version lacks an npm provenance/attestation — the symptom of a
# manual publish instead of the CD OIDC Trusted Publisher (what broke
# @faststore/diagnostics in the v4 launch). See scripts/check-provenance.mjs.
provenance:
name: npm provenance (informational)
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6

- name: Setup Node.js environment
uses: actions/setup-node@v6
with:
node-version: 24.13.0

- name: Check provenance of published packages
continue-on-error: true
run: node ./scripts/check-provenance.mjs
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ jobs:
- name: Verify publish manifests
run: pnpm release:verify

- name: Diagnose changed packages
run: node ./scripts/diagnose-changed.mjs

- name: Publish (main)
if: github.ref_name == 'main'
run: pnpm release
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
"test:coverage": "turbo run test:coverage",
"ci:dkcicd": "pnpm build && pnpm lint && pnpm size && pnpm test:coverage && mkdir -p coverage && pnpm dlx lcov-result-merger 'packages/*/coverage/lcov.info' coverage/lcov.info --prepend-source-files",
"size": "turbo run size",
"release": "lerna version --conventional-commits --conventional-graduate --yes && pnpm -r publish --access public --no-git-checks",
"release": "lerna version --conventional-commits --conventional-graduate --force-conventional-graduate --yes && pnpm -r publish --access public --no-git-checks",
"release:dev": "lerna version --conventional-commits --conventional-prerelease --preid=dev --force-publish --yes && pnpm -r publish --access public --tag dev --no-git-checks",
"release:v3": "lerna version --conventional-commits --yes && pnpm -r publish --access public --tag v3-latest --no-git-checks",
"release:v3": "lerna version --conventional-commits --force-publish --yes && pnpm -r publish --access public --tag v3-latest --no-git-checks",
"release:verify": "node ./scripts/verify-publish-manifests.mjs",
"clean": "turbo run clean && rm -rf node_modules",
"check:spec-kit": "node ./scripts/check-spec-kit.mjs",
Expand Down
176 changes: 176 additions & 0 deletions scripts/back-merge-resolve.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Resolves the *deterministic* merge conflicts produced when back-merging
// `main` into `dev` (after a release). It is meant to run while a `git merge`
// is in progress with conflicts, and it only touches conflicts whose
// resolution is unambiguous:
//
// 1. Version fields — `lerna.json` and every `packages/*/package.json`. In
// fixed mode the only conflicting hunk is `version`; we take the stable
// value from `main` (theirs) while keeping every other dev-side change in
// the file (new deps, scripts, etc.).
// 2. CHANGELOGs — `CHANGELOG.md` and `packages/*/CHANGELOG.md`. Both sides
// only ever *prepend* entries, so we union the conflicting blocks (ours
// first, then any theirs line not already present), never deleting an
// entry. This preserves `[no ci] Release: X.Y.Z` lines from both sides.
//
// Anything else (source files, generated files, lockfile) is intentionally
// left untouched — the workflow regenerates generated files / the lockfile and
// flags any remaining conflict for manual review. The script prints a summary
// and always exits 0; the caller decides what to do with leftover conflicts.

import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'

function git(args, opts = {}) {
return execFileSync('git', args, { encoding: 'utf8', ...opts })
}

function unmergedFiles() {
const out = git(['diff', '--name-only', '--diff-filter=U'])
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}

function isVersionFile(file) {
return file === 'lerna.json' || /^packages\/[^/]+\/package\.json$/.test(file)
}

function isChangelog(file) {
return (
file === 'CHANGELOG.md' || /^packages\/[^/]+\/CHANGELOG\.md$/.test(file)
)
}

// Read a specific merge stage of a conflicted file. Stage 2 = ours (dev),
// stage 3 = theirs (main). Returns null when the stage is absent.
function readStage(stage, file) {
try {
return git(['show', `:${stage}:${file}`])
} catch {
return null
}
}

// Take the dev-side manifest as the base (keeps dev-only edits) and only
// overwrite `version` with the stable value coming from main.
function resolveVersionFile(file) {
const ours = readStage(2, file)
const theirs = readStage(3, file)
if (ours == null || theirs == null) return false

const oursJson = JSON.parse(ours)
const theirsJson = JSON.parse(theirs)
if (typeof theirsJson.version !== 'string') return false

oursJson.version = theirsJson.version

// Preserve trailing newline style used across the repo manifests.
writeFileSync(file, `${JSON.stringify(oursJson, null, 2)}\n`)
git(['add', '--', file])
return theirsJson.version
}

// Union the two sides of every conflict block, ours first, appending theirs
// lines that are not already present (dedupe by trimmed, non-empty content).
function unionResolveContent(content) {
const lines = content.split('\n')
const out = []
let i = 0

while (i < lines.length) {
if (lines[i].startsWith('<<<<<<<')) {
i++ // skip the "<<<<<<< ours" marker
const ours = []
while (
i < lines.length &&
!lines[i].startsWith('|||||||') &&
!lines[i].startsWith('=======')
) {
ours.push(lines[i])
i++
}
// Skip an optional diff3 base section (||||||| ... =======).
if (i < lines.length && lines[i].startsWith('|||||||')) {
i++
while (i < lines.length && !lines[i].startsWith('=======')) i++
}
i++ // skip the "=======" marker
const theirs = []
while (i < lines.length && !lines[i].startsWith('>>>>>>>')) {
theirs.push(lines[i])
i++
}
i++ // skip the ">>>>>>>" marker

const merged = [...ours]
const seen = new Set(
ours.map((l) => l.trim()).filter((l) => l.length > 0)
)
for (const line of theirs) {
const key = line.trim()
if (key.length > 0 && seen.has(key)) continue
if (key.length > 0) seen.add(key)
merged.push(line)
}
out.push(...merged)
} else {
out.push(lines[i])
i++
}
}

return out.join('\n')
}

function resolveChangelog(file) {
// The working-tree copy still holds the conflict markers written by git.
let raw
try {
raw = readFileSync(file, 'utf8')
} catch {
return false
}
const resolved = unionResolveContent(raw)
writeFileSync(file, resolved)
git(['add', '--', file])
return true
}

function main() {
const conflicts = unmergedFiles()
if (conflicts.length === 0) {
console.log('No conflicts to resolve.')
return
}

const resolvedVersions = []
const resolvedChangelogs = []
const skipped = []

for (const file of conflicts) {
if (isVersionFile(file)) {
const version = resolveVersionFile(file)
if (version) resolvedVersions.push(`${file} -> ${version}`)
else skipped.push(file)
} else if (isChangelog(file)) {
if (resolveChangelog(file)) resolvedChangelogs.push(file)
else skipped.push(file)
} else {
skipped.push(file)
}
}

console.log('Back-merge deterministic resolution summary:')
console.log(` version files resolved: ${resolvedVersions.length}`)
for (const v of resolvedVersions) console.log(` ${v}`)
console.log(` CHANGELOGs unioned: ${resolvedChangelogs.length}`)
for (const c of resolvedChangelogs) console.log(` ${c}`)
if (skipped.length > 0) {
console.log(` left for the workflow: ${skipped.length}`)
for (const s of skipped) console.log(` ${s}`)
}
}

main()
Loading