diff --git a/.github/workflows/changelog-merge.yml b/.github/workflows/changelog-merge.yml new file mode 100644 index 0000000..c35f175 --- /dev/null +++ b/.github/workflows/changelog-merge.yml @@ -0,0 +1,155 @@ +name: Changelog merge + +# When main's CHANGELOG.md moves, open PRs that also add entries usually +# conflict there and nowhere else. This merges main into each such PR and, +# when CHANGELOG.md is the only conflict and scripts/changelog-merge.ts can +# keep both sides' [Unreleased] entries safely, pushes the merge commit to the +# PR branch. Otherwise it leaves the branch alone; if the resolver refused, it +# says why in one comment on the PR (updated in place, never repeated). +# +# Trust: only main's copy of the resolver runs. PR branches are merged with +# git and never built, installed or executed. Fork PRs are skipped. +# +# Pushes made with GITHUB_TOKEN don't start other workflows, so CI would not +# run on the merge commit, and GITHUB_TOKEN can't push a merge that brings in +# workflow changes from main. A GitHub App fixes both: set the repository +# variable CHANGELOG_BOT_CLIENT_ID and secret CHANGELOG_BOT_PRIVATE_KEY for an +# App installed on this repo with Contents, Pull requests and Workflows write +# (runbook: "Changelog conflicts"). Without it this falls back to GITHUB_TOKEN. + +on: + push: + branches: [main] + paths: [CHANGELOG.md] + # The Release workflow's bump commit is pushed with GITHUB_TOKEN, which does + # not fire `push`. It empties [Unreleased], so most open PRs conflict. + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +# A newer main makes an older run pointless. +concurrency: + group: changelog-merge + cancel-in-progress: true + +# Actions are pinned to commit SHAs (with the version in a comment) so a +# moved or compromised tag can't change what runs here. + +jobs: + resolve: + name: Resolve CHANGELOG conflicts in open PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + # Only for the fallback (no App): push to PR branches and comment on PRs. + permissions: + contents: write + pull-requests: write + steps: + - name: App token + id: app + if: vars.CHANGELOG_BOT_CLIENT_ID != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.CHANGELOG_BOT_CLIENT_ID }} + private-key: ${{ secrets.CHANGELOG_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + # main, and with it the resolver that runs. Credentials are kept so the + # PR worktrees below can push. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app.outputs.token || github.token }} + + # No pnpm install: the resolver only uses Node built-ins. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + + - name: Merge main into PRs and resolve CHANGELOG.md + env: + GH_TOKEN: ${{ steps.app.outputs.token || github.token }} + REPO: ${{ github.repository }} + WITH_APP: ${{ steps.app.outputs.token != '' }} + run: | + set -euo pipefail + resolver="${GITHUB_WORKSPACE}/scripts/merge-changelog.ts" + marker="" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + main="$(git rev-parse --short origin/main)" + + # One comment per PR, edited in place. + comment() { + local number="$1" body="$2" id + body="${marker}"$'\n'"${body}" + id="$(gh api "repos/${REPO}/issues/${number}/comments" --paginate \ + --jq "[.[] | select(.body | startswith(\"${marker}\"))][0].id // empty")" + if [ -n "${id}" ]; then + gh api --method PATCH "repos/${REPO}/issues/comments/${id}" -f body="${body}" >/dev/null + else + gh api --method POST "repos/${REPO}/issues/${number}/comments" -f body="${body}" >/dev/null + fi + } + + { + echo "### CHANGELOG conflicts against main (${main})" + echo + echo "| PR | Outcome |" + echo "| --- | --- |" + } >> "${GITHUB_STEP_SUMMARY}" + + prs="$(gh pr list --repo "${REPO}" --base main --state open --limit 200 \ + --json number,headRefName,isCrossRepository,files \ + --jq '.[] | select(.isCrossRepository | not) + | select(any(.files[]; .path == "CHANGELOG.md")) + | "\(.number) \(.headRefName)"')" + + while read -r number branch; do + [ -n "${number}" ] || continue + dir="${RUNNER_TEMP}/pr-${number}" + # The branch may be gone by now; that is not this run's problem. + if ! git fetch -q origin "+refs/heads/${branch}:refs/remotes/origin/${branch}" || + ! git worktree add -q --detach "${dir}" "refs/remotes/origin/${branch}"; then + echo "| #${number} | Could not fetch the branch |" >> "${GITHUB_STEP_SUMMARY}" + continue + fi + + if git -C "${dir}" merge -q --no-commit --no-ff origin/main >/dev/null 2>&1; then + outcome="No conflict" + git -C "${dir}" merge --abort 2>/dev/null || true + elif [ "$(git -C "${dir}" diff --name-only --diff-filter=U)" != "CHANGELOG.md" ]; then + outcome="Other files conflict too; left alone" + git -C "${dir}" merge --abort + elif reason="$(cd "${dir}" && node --no-warnings "${resolver}" --git 2>&1)"; then + git -C "${dir}" commit -q -m "Merge main into ${branch}, keeping both CHANGELOG entries" + if git -C "${dir}" push -q origin "HEAD:refs/heads/${branch}"; then + outcome="Resolved and pushed" + if [ "${WITH_APP}" = "true" ]; then + ci="CI runs on it as usual." + else + ci="GitHub doesn't start CI for this bot's pushes, so CI hasn't run on it: push again, or close and reopen the PR." + fi + comment "${number}" "Merged \`main\` (${main}) into this branch: \`CHANGELOG.md\` was the only conflict, and both sides' [Unreleased] entries are kept. Pull before pushing again. ${ci}" || true + else + # The branch moved meanwhile, or (without the App) main's + # merge brings in workflow changes GITHUB_TOKEN can't push. + outcome="Resolved, but the push was refused" + fi + else + outcome="Not safe to resolve: ${reason}" + git -C "${dir}" merge --abort + comment "${number}" "\`CHANGELOG.md\` conflicts with \`main\` (${main}) and can't be resolved automatically. ${reason}" || true + fi + + git worktree remove --force "${dir}" + echo "| #${number} | ${outcome//|/\\|} |" >> "${GITHUB_STEP_SUMMARY}" + echo "#${number} (${branch}): ${outcome}" + done <<< "${prs}" diff --git a/AGENTS.md b/AGENTS.md index 5737c9d..e626423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ just secrets-pull dev # pull that env's app secrets from Key Vault into .env.dev just secrets-push dev # diff local app secrets against Key Vault and push on a yes (never prod from an agent) just restart dev # restart that env's Container App so it reads its secrets again just check-changelog # fail if this branch does not change CHANGELOG.md vs origin/main +just changelog-merge # in a stopped merge/rebase: keep both sides' CHANGELOG [Unreleased] entries when safe just bump # cut [Unreleased] into a dated version and bump package.json just tf-validate # terraform fmt -check and validate bootstrap + envs/dev just tf-plan dev # plan the dev stack (needs Azure login; does not apply) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfad26b..4048964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Until 1.0.0, releases stay on 0.x: `Added` bumps minor, `Fixed`/`Changed` (and t ## [Unreleased] +### Added + +- CHANGELOG conflicts resolve themselves when that is safe. When `main`'s `CHANGELOG.md` changes (and after each Release), a new workflow merges `main` into every open PR from this repo that edits it. If `CHANGELOG.md` is the only conflict, it keeps both sides' `[Unreleased]` entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new `[Unreleased]` after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, `just changelog-merge` does the same in a stopped merge or rebase, and `just changelog-merge-driver` makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts"). + ### Changed - CI jobs run only when the files they depend on have not already passed. Each workflow's first job hashes the job's inputs (a list in `.github/path-filters.yml`, plus the workflow running it) and looks for a marker that an earlier run with exactly those files succeeded. So pushing only a CHANGELOG entry, re-pushing the same code, or opening a docs-only PR no longer runs `just check`, the Docker build, the docs site build or Terraform validate again. Skipped jobs no longer start a runner. Test-only changes no longer build or publish an image. CD publishes when the image inputs differ from the last publish, so a CD run cancelled by a newer merge no longer leaves code without a GHCR image. Terraform plan jobs that aren't needed never start, so they never wait on an Environment. The changelog gate no longer runs `pnpm install`. `just ci-inputs ` shows what a list matches; the runbook's "CI and CD" says how to force a rerun. diff --git a/docs/architecture.md b/docs/architecture.md index bedb8ee..10287d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -481,7 +481,7 @@ There is **no database**. Access, schedules and (planned) account linking are fi - **Terraform layout:** `infra/bootstrap` (state storage, GitHub OIDC, applied) then `infra/modules/pixel`, `infra/envs/dev` (#9) and `infra/envs/prod` (#12). Secret values never go into Terraform state; apply can take them as ephemeral write-only inputs. Subscription `d150e252-e2f0-47fb-8a4a-c3f29e9aebd4`, West Europe. See [ADR 0008](adr/0008-terraform-bootstrap.md), [ADR 0009](adr/0009-container-apps-dev.md) and [ADR 0012](adr/0012-prod-environment.md). CD does **not** apply Terraform. After first create, **dev** apply ignores the Pixel container image; `just deploy-dev` / `az` updates it. Prod apply sets `container_image`. Coding agents must not apply prod (`PIXEL_APPLY_PROD=1` required). - **Home Assistant from Azure:** Tailscale sidecar into the space network is the preferred path (`tailscale_enabled`); `HOME_ASSISTANT_URL` can still be a Nabu Casa URL. Both URL and token or neither. Sidecar is userspace (no TUN). LAN MagicDNS through it is remaining work for #43. - **Cost (rough):** always-on `dev` is about €40–55/month West Europe without Tailscale, plus about €15 with the sidecar. Not a quote. -- **CI (built):** [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs on every PR and on pushes to `main`. Each expensive job depends on a list of files in [`.github/path-filters.yml`](../.github/path-filters.yml) (plus the workflow running it): **check** (`just check` + `just build`) on app/scripts/config/tooling files; **Docker smoke** on what the Dockerfile copies (`src/` without tests, `content/`, Dockerfile, lockfile, tsconfigs). A cheap first job hashes those files ([`scripts/ci-inputs.sh`](../scripts/ci-inputs.sh)) and looks the hash up in a tiny Actions cache marker ([`.github/actions/ci-inputs`](../.github/actions/ci-inputs/action.yml)); the job runs only if that exact set of files has not passed before, and saves the marker when it succeeds ([`ci-passed`](../.github/actions/ci-passed/action.yml)). So a push that only adds a CHANGELOG entry, a re-push of the same code, or a docs-only PR (main's markers are visible to PRs) skips them, and a cancelled or failed run saves nothing and runs again. Skipped jobs never start a runner; GitHub shows them as passed. [`changelog.yml`](../.github/workflows/changelog.yml) still runs on every PR, without a `pnpm install` (Node runs the gate's TypeScript itself). [`docs.yml`](../.github/workflows/docs.yml) Biome-lints and builds the site when its `docs` inputs change and have not passed. [`terraform.yml`](../.github/workflows/terraform.yml) starts only when `infra/`, that workflow, the `justfile` or the gate change, skips everything if its `terraform` inputs already passed, and runs `terraform plan` on each env via GitHub Environment `dev` / `prod` OIDC **only if `infra/` Terraform files changed** (markdown does not); plan jobs that aren't needed never start, so they never wait on an Environment. Missing `ARM_*` **fails closed**. No apply on merge. GitHub is not Key Vault Secrets User, so a secret refresh would 403 (ADR 0012). Actions are pinned to commit SHAs. `just ci-inputs ` shows what a list matches. +- **CI (built):** [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs on every PR and on pushes to `main`. Each expensive job depends on a list of files in [`.github/path-filters.yml`](../.github/path-filters.yml) (plus the workflow running it): **check** (`just check` + `just build`) on app/scripts/config/tooling files; **Docker smoke** on what the Dockerfile copies (`src/` without tests, `content/`, Dockerfile, lockfile, tsconfigs). A cheap first job hashes those files ([`scripts/ci-inputs.sh`](../scripts/ci-inputs.sh)) and looks the hash up in a tiny Actions cache marker ([`.github/actions/ci-inputs`](../.github/actions/ci-inputs/action.yml)); the job runs only if that exact set of files has not passed before, and saves the marker when it succeeds ([`ci-passed`](../.github/actions/ci-passed/action.yml)). So a push that only adds a CHANGELOG entry, a re-push of the same code, or a docs-only PR (main's markers are visible to PRs) skips them, and a cancelled or failed run saves nothing and runs again. Skipped jobs never start a runner; GitHub shows them as passed. [`changelog.yml`](../.github/workflows/changelog.yml) still runs on every PR, without a `pnpm install` (Node runs the gate's TypeScript itself). [`changelog-merge.yml`](../.github/workflows/changelog-merge.yml) runs when `main`'s `CHANGELOG.md` changes (and after a Release): it merges `main` into each open same-repo PR that edits it and, when `CHANGELOG.md` is the only conflict and [`scripts/changelog-merge.ts`](../scripts/changelog-merge.ts) can keep both sides' `[Unreleased]` entries safely, pushes the merge (with a GitHub App token if one is set up, so CI runs on it). Only `main`'s resolver runs; PR code is never executed. [`docs.yml`](../.github/workflows/docs.yml) Biome-lints and builds the site when its `docs` inputs change and have not passed. [`terraform.yml`](../.github/workflows/terraform.yml) starts only when `infra/`, that workflow, the `justfile` or the gate change, skips everything if its `terraform` inputs already passed, and runs `terraform plan` on each env via GitHub Environment `dev` / `prod` OIDC **only if `infra/` Terraform files changed** (markdown does not); plan jobs that aren't needed never start, so they never wait on an Environment. Missing `ARM_*` **fails closed**. No apply on merge. GitHub is not Key Vault Secrets User, so a secret refresh would 403 (ADR 0012). Actions are pinned to commit SHAs. `just ci-inputs ` shows what a list matches. - **CD:** `just deploy-dev` for Azure `dev` (local GHCR push + `az` + `just register dev` + Sentry release). Production slash commands: the Release's `register-prod` job (fallback `just register prod`). [`.github/workflows/cd.yml`](../.github/workflows/cd.yml) on `main` only, and only when the **image** inputs differ from the last run that published (so a revert publishes again, and a run cancelled by a newer merge leaves the newer one to publish): publish with `GITHUB_TOKEN` (`packages: write` on that job) and upload Sentry source maps (fails closed if org/token are missing). CD runs queue and are never cancelled, so a quick docs-only merge cannot cut short the publish before it. **No Azure login and no `pixel-prod` update** on merge. [`.github/workflows/release.yml`](../.github/workflows/release.yml) (`workflow_dispatch` on `main`) runs `just check` on the `main` commit being released (CI on `main` may have been cancelled or red), cuts the version only if that passes, publishes `:vX.Y.Z`, Environment `prod` approval rolls `pixel-prod` (`az`, min=max=1, no Terraform apply), a second approval runs `register-prod` (reads only `discord-token` from `pixel-prod-kv`), and publishes GitHub Pages. See [ADR 0010](adr/0010-ghcr-cd.md), [ADR 0012](adr/0012-prod-environment.md) and [ADR 0013](adr/0013-release-workflow.md). ### CI and CD pipelines @@ -527,6 +527,8 @@ flowchart TD pub --> maps[Sentry source maps] maps --> mark[Remember these inputs as published] imagep -->|yes| nocd[Skip GHCR and Sentry] + main --> clogp{"CHANGELOG.md changed?"} + clogp -->|yes| clogm["Changelog merge: merge main into open PRs, push if only CHANGELOG conflicts and it's safe"] main --> note[Does not roll pixel-prod] ``` @@ -545,6 +547,7 @@ flowchart TD roll --> approve2[Environment prod approval] approve2 --> register[Register prod slash commands] tag --> pages[VitePress to GitHub Pages] + bump --> clogm[Changelog merge into open PRs] ``` ## Adding a platform (later) diff --git a/docs/runbook.md b/docs/runbook.md index f5301c0..8552b70 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -497,6 +497,8 @@ flowchart TD pub --> maps[Sentry source maps] maps --> mark[Remember these inputs as published] imagep -->|yes| nocd[Skip GHCR and Sentry] + main --> clogp{"CHANGELOG.md changed?"} + clogp -->|yes| clogm["Changelog merge: merge main into open PRs, push if only CHANGELOG conflicts and it's safe"] main --> note[Does not roll pixel-prod] ``` @@ -515,6 +517,7 @@ flowchart TD roll --> approve2[Environment prod approval] approve2 --> register[Register prod slash commands] tag --> pages[VitePress to GitHub Pages] + bump --> clogm[Changelog merge into open PRs] ``` ## Changelog and versions @@ -523,6 +526,15 @@ User-facing and operator-facing changes are recorded in [`CHANGELOG.md`](../CHAN **On every pull request** that users or operators would notice (commands, behaviour, config, deploy, monitoring, this runbook): add a short bullet under `## [Unreleased]` in the right category (`Added`, `Changed`, `Fixed`, …). CI fails the PR if `CHANGELOG.md` is untouched. Pure chores (typos, CI-only, no user or operator impact) may use the `skip-changelog` label instead; adding or removing that label re-runs the check. Create the label on the repo once if it does not exist yet. +**Changelog conflicts.** Two PRs that both add entries conflict in `CHANGELOG.md` once one merges, and after a release every open PR does. Keeping both sides is almost always right, so: + +- **On GitHub,** [`.github/workflows/changelog-merge.yml`](../.github/workflows/changelog-merge.yml) runs whenever `main`'s `CHANGELOG.md` changes (and after each Release). For every open PR from this repo that edits it, it merges `main` in. If `CHANGELOG.md` is the only conflict and it's safe, it pushes the merge commit and comments on the PR. If it isn't safe, it comments once, saying why, and changes nothing. Fork PRs and PRs with other conflicts are left alone. The run's summary lists each PR's outcome. +- **On your laptop,** in a merge or rebase that stopped on `CHANGELOG.md`: `just changelog-merge`. To have git do it by itself in this clone: `just changelog-merge-driver` (undo with `git config --unset merge.changelog.driver`). + +"Safe" means [`scripts/changelog-merge.ts`](../scripts/changelog-merge.ts) can do it without guessing. The branch's new `[Unreleased]` entries go at the end of their section (no section is ever added twice), and an entry both sides added is kept once (or left out, if `main` has already released it). After a release, the branch's entries go into the new, empty `[Unreleased]`. It refuses, and leaves the conflict to you, when both sides changed the same entry, when the branch changed an entry that has since been released, when either side has a section twice, an unknown `###` heading or text that isn't a list entry, or when both sides changed the released versions, the intro or the links. + +Setting it up: pushes made with `GITHUB_TOKEN` don't start CI, and can't bring workflow changes from `main` into a branch. So create a GitHub App on the org with **Contents**, **Pull requests** and **Workflows**: read and write, install it on `pixelbar/pixel`, then set the repository variable `CHANGELOG_BOT_CLIENT_ID` (its client ID) and the secret `CHANGELOG_BOT_PRIVATE_KEY` (a private key). Until then the workflow pushes with `GITHUB_TOKEN`. Its comment then says CI hasn't run (push again, or close and reopen the PR), and a merge that brings in workflow changes is refused. + **To cut a version** (move Unreleased into a dated section and bump `package.json`): ```sh diff --git a/justfile b/justfile index 69734ca..0b20659 100644 --- a/justfile +++ b/justfile @@ -57,6 +57,19 @@ ci-inputs filter: check-changelog *args: node scripts/check-changelog.ts {{args}} +# In a merge or rebase that stopped on CHANGELOG.md: keep both sides' [Unreleased] +# entries when that is clearly right, and stage the file. Says why when it won't. +changelog-merge: + node scripts/merge-changelog.ts --git + +# Let git do `just changelog-merge` by itself whenever CHANGELOG.md conflicts +# (this clone only; nothing is committed). Undo: git config --unset merge.changelog.driver +changelog-merge-driver: + git config merge.changelog.name "Keep both sides' CHANGELOG [Unreleased] entries" + git config merge.changelog.driver "node scripts/merge-changelog.ts %O %A %B" + grep -qxF 'CHANGELOG.md merge=changelog' "$(git rev-parse --git-path info/attributes)" 2>/dev/null || \ + echo 'CHANGELOG.md merge=changelog' >> "$(git rev-parse --git-path info/attributes)" + # Cut [Unreleased] into a dated version and bump package.json. # Added → minor; Fixed/Changed → patch. Never major before 1.0. # `--commit` also creates the release commit and tag (used by the Release workflow). diff --git a/scripts/changelog-merge.test.ts b/scripts/changelog-merge.test.ts new file mode 100644 index 0000000..b058957 --- /dev/null +++ b/scripts/changelog-merge.test.ts @@ -0,0 +1,303 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mergeChangelogs, parseSections } from "./changelog-merge.ts"; + +const HEAD = `# Changelog + +Intro. + +`; +const RELEASED = `## [0.2.0] - 2026-01-02 + +### Added + +- Old thing. + +`; +const LINKS = `[Unreleased]: https://example.invalid/compare/v0.2.0...HEAD +[0.2.0]: https://example.invalid/releases/tag/v0.2.0 +`; + +/** A changelog with these [Unreleased] sections. */ +function doc(unreleased: string, released = RELEASED, links = LINKS, head = HEAD): string { + return `${head}## [Unreleased]\n\n${unreleased ? `${unreleased}\n\n` : ""}${released}${links}`; +} + +const merge = (base: string, branch: string, main: string) => + mergeChangelogs({ base, branch, main }); + +function merged(base: string, branch: string, main: string): string { + const result = merge(base, branch, main); + if (!result.ok) throw new Error(result.reason); + return result.text; +} + +function refused(base: string, branch: string, main: string): string { + const result = merge(base, branch, main); + if (result.ok) throw new Error("expected a refusal"); + return result.reason; +} + +describe("mergeChangelogs", () => { + const base = doc("### Fixed\n\n- Base fix."); + + it("keeps both sides' entries in one section, main's first", () => { + const branch = doc("### Fixed\n\n- Base fix.\n- Branch fix."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(merged(base, branch, main)).toBe( + doc("### Fixed\n\n- Base fix.\n- Main fix.\n- Branch fix."), + ); + }); + + it("adds a section in Keep a Changelog order, never a second one", () => { + const branch = doc("### Added\n\n- New command.\n\n### Fixed\n\n- Base fix.\n- Branch fix."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix.\n\n### Security\n\n- Main hardening."); + const text = merged(base, branch, main); + expect(text).toBe( + doc( + "### Added\n\n- New command.\n\n### Fixed\n\n- Base fix.\n- Main fix.\n- Branch fix.\n\n### Security\n\n- Main hardening.", + ), + ); + expect(text.match(/^### Fixed$/gm)).toHaveLength(1); + }); + + it("keeps an entry both sides added only once", () => { + const branch = doc("### Fixed\n\n- Base fix.\n- Same fix."); + const main = doc("### Fixed\n\n- Base fix.\n- Same fix."); + expect(merged(base, branch, doc("### Fixed\n\n- Base fix.\n- Same fix.\n- Other."))).toBe( + doc("### Fixed\n\n- Base fix.\n- Same fix.\n- Other."), + ); + expect(merged(base, branch, main)).toBe(main); + }); + + it("doesn't add an entry main has already released", () => { + const released = `## [0.3.0] - 2026-02-01\n\n### Fixed\n\n- Base fix.\n- Same fix.\n Two lines.\n\n${RELEASED}`; + const main = doc("", released); + const branch = doc( + "### Fixed\n\n- Base fix.\n- Same fix.\n Two lines.\n\n### Added\n\n- New.", + ); + expect(merged(base, branch, main)).toBe(doc("### Added\n\n- New.", released)); + // Still adds an entry whose released twin is only similar. + const close = doc("### Fixed\n\n- Base fix.\n- Same fix."); + expect(merged(base, close, main)).toBe(doc("### Fixed\n\n- Same fix.", released)); + }); + + it("keeps multi-line entries whole", () => { + const entry = "- Branch fix that wraps\n onto a second line.\n\n And a second paragraph."; + const branch = doc(`### Fixed\n\n- Base fix.\n${entry}`); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(merged(base, branch, main)).toBe(doc(`### Fixed\n\n- Base fix.\n- Main fix.\n${entry}`)); + }); + + it("applies the branch rewording an entry main left alone", () => { + const branch = doc("### Fixed\n\n- Base fix, reworded."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(merged(base, branch, main)).toBe(doc("### Fixed\n\n- Main fix.\n- Base fix, reworded.")); + }); + + it("puts the branch's entries in the new [Unreleased] after a release on main", () => { + const released = `## [0.3.0] - 2026-02-01\n\n### Fixed\n\n- Base fix.\n\n${RELEASED}`; + const links = `[Unreleased]: https://example.invalid/compare/v0.3.0...HEAD\n[0.3.0]: x\n[0.2.0]: y\n`; + const main = doc("", released, links); + const branch = doc("### Fixed\n\n- Base fix.\n\n### Added\n\n- Branch feature."); + expect(merged(base, branch, main)).toBe(doc("### Added\n\n- Branch feature.", released, links)); + }); + + it("takes the only side that changed the head, the released versions or the links", () => { + const branch = doc( + "### Fixed\n\n- Base fix.\n- Branch fix.", + RELEASED, + LINKS, + "# Changelog\n\nNew intro.\n\n", + ); + const main = doc( + "### Fixed\n\n- Base fix.\n- Main fix.", + `${RELEASED}## [0.1.0] - 2026-01-01\n\n`, + ); + expect(merged(base, branch, main)).toBe( + doc( + "### Fixed\n\n- Base fix.\n- Main fix.\n- Branch fix.", + `${RELEASED}## [0.1.0] - 2026-01-01\n\n`, + LINKS, + "# Changelog\n\nNew intro.\n\n", + ), + ); + }); + + it("short-cuts when only one side changed", () => { + const changed = doc("### Fixed\n\n- Base fix.\n- More."); + expect(merged(base, base, changed)).toBe(changed); + expect(merged(base, changed, base)).toBe(changed); + expect(merged(base, changed, changed)).toBe(changed); + }); + + describe("refuses", () => { + it("when both sides changed the same entry", () => { + const branch = doc("### Fixed\n\n- Base fix, branch wording."); + const main = doc("### Fixed\n\n- Base fix, main wording."); + expect(refused(base, branch, main)).toMatch( + /changed or removed an \[Unreleased\] Fixed entry/, + ); + }); + + it("when the branch changed an entry main has since released", () => { + const released = `## [0.3.0] - 2026-02-01\n\n### Fixed\n\n- Base fix.\n\n${RELEASED}`; + const main = doc("", released); + const branch = doc("### Fixed\n\n- Base fix, reworded."); + expect(refused(base, branch, main)).toMatch(/has released/); + }); + + it("when a side has a section twice", () => { + const branch = doc("### Fixed\n\n- Base fix.\n\n### Fixed\n\n- Branch fix."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(refused(base, branch, main)).toMatch(/### Fixed twice/); + }); + + it("when a heading is not a Keep a Changelog section", () => { + const branch = doc("### Fixed\n\n- Base fix.\n\n### Misc\n\n- Branch thing."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(refused(base, branch, main)).toMatch(/isn't a Keep a Changelog section/); + }); + + it("when [Unreleased] has text that is not a list entry", () => { + const branch = doc("Some prose.\n\n### Fixed\n\n- Base fix."); + const main = doc("### Fixed\n\n- Base fix.\n- Main fix."); + expect(refused(base, branch, main)).toMatch(/isn't a list entry/); + expect(refused(base, doc("- Loose entry.\n\n### Fixed\n\n- Base fix."), main)).toMatch( + /outside a ### section/, + ); + }); + + it("when both sides changed the released versions", () => { + const branch = doc( + "### Fixed\n\n- Base fix.\n- B.", + RELEASED.replace("Old thing", "Branch edit"), + ); + const main = doc( + "### Fixed\n\n- Base fix.\n- M.", + RELEASED.replace("Old thing", "Main edit"), + ); + expect(refused(base, branch, main)).toBe("Both sides changed the released versions."); + }); + + it("when a side still has conflict markers", () => { + const branch = doc("### Fixed\n\n<<<<<<< HEAD\n- Base fix.\n=======\n- x\n>>>>>>> main"); + expect(refused(base, branch, doc("### Fixed\n\n- Base fix.\n- M."))).toMatch( + /conflict markers/, + ); + }); + + it("when a side has no [Unreleased]", () => { + expect(refused(base, "# Changelog\n\nnothing\n", doc("### Fixed\n\n- M."))).toMatch( + /must contain a ## \[Unreleased\]/, + ); + }); + }); + + // Shaped like the real CHANGELOG.md (not read from it: changelog-only + // pushes skip `just check`, so a test must not depend on that file). + it("keeps the head, released versions and links byte for byte", () => { + const head = + "# Changelog\n\nAll notable changes.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).\n\n"; + const released = + "## [0.2.0] - 2026-02-02\n\n### Added\n\n- One.\n\n- Two, after a blank line.\n\n## [0.1.0] - 2026-01-01\n\n### Fixed\n\n- `code` and [a link](https://example.invalid).\n\n"; + const links = + "[Unreleased]: https://example.invalid/compare/v0.2.0...HEAD\n[0.2.0]: https://example.invalid/compare/v0.1.0...v0.2.0\n[0.1.0]: https://example.invalid/releases/tag/v0.1.0\n"; + const real = (unreleased: string) => + `${head}## [Unreleased]\n\n${unreleased}${released}${links}`; + const text = merged( + real("### Fixed\n\n- Base.\n\n"), + real("### Fixed\n\n- Base.\n- Branch.\n\n"), + real("### Fixed\n\n- Base.\n- Main.\n\n"), + ); + expect(text).toBe(real("### Fixed\n\n- Base.\n- Main.\n- Branch.\n\n")); + }); +}); + +describe("parseSections", () => { + it("reads whole entries per section", () => { + expect(parseSections("### Added\n\n- One\n more.\n\n- Two.\n\n### Fixed\n\n* Three.")).toEqual( + new Map([ + ["Added", ["- One\n more.", "- Two."]], + ["Fixed", ["* Three."]], + ]), + ); + }); +}); + +describe("merge-changelog.ts", () => { + const SCRIPT = resolve("scripts/merge-changelog.ts"); + let dir: string; + const git = (...args: string[]) => + execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.invalid", ...args], { + cwd: dir, + encoding: "utf8", + stdio: "pipe", + }); + const node = (...args: string[]) => + execFileSync("node", [SCRIPT, ...args], { cwd: dir, encoding: "utf8", stdio: "pipe" }); + const write = (text: string) => writeFileSync(join(dir, "CHANGELOG.md"), text); + const base = doc("### Fixed\n\n- Base fix."); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "pixel-changelog-merge-")); + git("init", "-q", "-b", "main"); + write(base); + git("add", "-A"); + git("commit", "-qm", "base"); + git("checkout", "-qb", "branch"); + write(doc("### Fixed\n\n- Base fix.\n- Branch fix.")); + git("commit", "-qam", "branch"); + git("checkout", "-q", "main"); + write(doc("### Fixed\n\n- Base fix.\n- Main fix.")); + git("commit", "-qam", "main"); + git("checkout", "-q", "branch"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const result = () => readFileSync(join(dir, "CHANGELOG.md"), "utf8"); + const expected = doc("### Fixed\n\n- Base fix.\n- Main fix.\n- Branch fix."); + + it("resolves a stopped merge from the index and stages it", () => { + expect(() => git("merge", "main")).toThrow(); + expect(node("--git")).toContain("kept both sides"); + expect(result()).toBe(expected); + expect(git("diff", "--name-only", "--diff-filter=U")).toBe(""); + }); + + it("knows a rebase swaps the sides", () => { + expect(() => git("rebase", "main")).toThrow(); + node("--git"); + expect(result()).toBe(expected); + }); + + it("works as a merge driver", () => { + git("config", "merge.changelog.driver", `node ${SCRIPT} %O %A %B`); + writeFileSync(join(dir, ".git/info/attributes"), "CHANGELOG.md merge=changelog\n"); + git("merge", "-q", "--no-edit", "main"); + expect(result()).toBe(expected); + }); + + it("leaves conflict markers as a driver when it refuses", () => { + git("config", "merge.changelog.driver", `node ${SCRIPT} %O %A %B`); + writeFileSync(join(dir, ".git/info/attributes"), "CHANGELOG.md merge=changelog\n"); + write(doc("### Fixed\n\n- Base fix, branch wording.")); + git("commit", "-qam", "reword"); + git("checkout", "-q", "main"); + write(doc("### Fixed\n\n- Base fix, main wording.")); + git("commit", "-qam", "reword"); + git("checkout", "-q", "branch"); + expect(() => git("merge", "main")).toThrow(); + expect(result()).toMatch(/^<{7} current$/m); + }); + + it("refuses when CHANGELOG.md is not in conflict", () => { + expect(() => node("--git")).toThrow(); + }); +}); diff --git a/scripts/changelog-merge.ts b/scripts/changelog-merge.ts new file mode 100644 index 0000000..0fa1e52 --- /dev/null +++ b/scripts/changelog-merge.ts @@ -0,0 +1,203 @@ +/** + * Resolve a CHANGELOG.md merge conflict by keeping both sides' [Unreleased] + * entries, when that is clearly right. The CLI is merge-changelog.ts. + * + * `branch` is the side being merged in (a PR), `main` the side it lands on. + * The result is main's file with the branch's [Unreleased] changes applied: + * entries it added go at the end of their section (a section is created in + * the Keep a Changelog order if main has none; an entry main already has, + * in [Unreleased] or a released version, is not added again), and entries it removed or + * reworded are removed only if main still has them unchanged. Anything else + * (both sides edited the same entry, an entry that main has since released, + * duplicated or unknown section headings, text that isn't a list entry, or + * both sides changing the released versions) is left for a person. + * + * A release on main between the two is fine: its [Unreleased] is then empty + * (or new), and the branch's added entries land there, not in the version + * that was cut. + */ +import { CHANGELOG_SECTIONS, type ChangelogSection, parseChangelog } from "./changelog.ts"; + +export type ChangelogMergeInput = { + /** The common ancestor. */ + base: string; + /** The side being merged in (the PR branch). */ + branch: string; + /** The side it lands on (main). */ + main: string; +}; + +export type ChangelogMergeResult = { ok: true; text: string } | { ok: false; reason: string }; + +type Sections = Map; + +class Unmergeable extends Error {} + +const UNRELEASED_HEADING = "## [Unreleased]"; +const CONFLICT_MARKER = /^(<{7}|={7}|>{7})( |$)/m; +const ENTRY_START = /^[-*]\s+\S/; + +export function mergeChangelogs({ base, branch, main }: ChangelogMergeInput): ChangelogMergeResult { + try { + for (const [side, text] of [ + ["base", base], + ["branch", branch], + ["main", main], + ] as const) { + if (CONFLICT_MARKER.test(text)) + throw new Unmergeable(`The ${side} side has conflict markers.`); + } + if (branch === main || branch === base) return { ok: true, text: main }; + if (main === base) return { ok: true, text: branch }; + + const [b, br, m] = [base, branch, main].map(parse) as [Parsed, Parsed, Parsed]; + const preamble = pick(b.preamble, br.preamble, m.preamble, "the text above [Unreleased]"); + const released = pick(b.rest, br.rest, m.rest, "the released versions"); + const links = pick(b.links, br.links, m.links, "the links at the bottom"); + + const result = cloneSections(m.sections); + for (const [section, entries] of b.sections) { + const kept = br.sections.get(section) ?? []; + for (const entry of entries) { + if (kept.includes(entry)) continue; + // The branch removed or reworded it. Only safe if main didn't touch it. + const onMain = result.get(section) ?? []; + const at = onMain.indexOf(entry); + if (at === -1) { + throw new Unmergeable( + `The branch changed or removed an [Unreleased] ${section} entry that main also changed or has released: ${preview(entry)}`, + ); + } + onMain.splice(at, 1); + } + } + // An entry already in a released version (say the same change landed + // through another PR and was released since) is not unreleased again. + const shipped = releasedEntries(released); + for (const [section, entries] of br.sections) { + const before = b.sections.get(section) ?? []; + for (const entry of entries) { + if (before.includes(entry) || shipped.has(entry)) continue; + const onMain = result.get(section) ?? []; + if (!onMain.includes(entry)) onMain.push(entry); + result.set(section, onMain); + } + } + + return { + ok: true, + text: `${preamble}${UNRELEASED_HEADING}\n\n${renderSections(result)}${released}${links}`, + }; + } catch (error) { + if (error instanceof Unmergeable) return { ok: false, reason: error.message }; + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } +} + +type Parsed = { preamble: string; sections: Sections; rest: string; links: string }; + +function parse(text: string): Parsed { + const doc = parseChangelog(text); + return { ...doc, sections: parseSections(doc.unreleased) }; +} + +/** The [Unreleased] body as sections of whole entries (a bullet and its indented lines). */ +export function parseSections(body: string): Sections { + const sections: Sections = new Map(); + let current: string[] | undefined; + let entry: string[] | undefined; + const flush = () => { + if (entry && current) { + while (entry.at(-1) === "") entry.pop(); + current.push(entry.join("\n")); + } + entry = undefined; + }; + for (const line of body.split("\n")) { + if (line.startsWith("#")) { + flush(); + const name = /^### (\S.*?)\s*$/.exec(line)?.[1]; + if (!name || !isSection(name)) { + throw new Unmergeable( + `[Unreleased] has a heading that isn't a Keep a Changelog section: ${preview(line)}`, + ); + } + if (sections.has(name)) throw new Unmergeable(`[Unreleased] has ### ${name} twice.`); + current = []; + sections.set(name, current); + continue; + } + if (ENTRY_START.test(line)) { + flush(); + if (!current) throw new Unmergeable("[Unreleased] has an entry outside a ### section."); + entry = [line.trimEnd()]; + continue; + } + if (line.trim() === "") { + entry?.push(""); + continue; + } + if (entry && /^\s/.test(line)) { + entry.push(line.trimEnd()); + continue; + } + throw new Unmergeable(`[Unreleased] has text that isn't a list entry: ${preview(line)}`); + } + flush(); + return sections; +} + +/** Every entry in the released versions, read like [Unreleased] but never refusing. */ +function releasedEntries(released: string): Set { + const entries = new Set(); + let entry: string[] | undefined; + const flush = () => { + if (entry) { + while (entry.at(-1) === "") entry.pop(); + entries.add(entry.join("\n")); + } + entry = undefined; + }; + for (const line of released.split("\n")) { + if (ENTRY_START.test(line)) { + flush(); + entry = [line.trimEnd()]; + } else if (line.trim() === "") { + entry?.push(""); + } else if (entry && /^\s/.test(line)) { + entry.push(line.trimEnd()); + } else { + flush(); + } + } + flush(); + return entries; +} + +function renderSections(sections: Sections): string { + const blocks = CHANGELOG_SECTIONS.flatMap((name) => { + const entries = sections.get(name) ?? []; + return entries.length === 0 ? [] : [`### ${name}\n\n${entries.join("\n")}\n\n`]; + }); + return blocks.join(""); +} + +/** A three-way pick for a part only one side may change. */ +function pick(base: string, branch: string, main: string, what: string): string { + if (branch === base || branch === main) return main; + if (main === base) return branch; + throw new Unmergeable(`Both sides changed ${what}.`); +} + +function cloneSections(sections: Sections): Sections { + return new Map([...sections].map(([name, entries]) => [name, [...entries]])); +} + +function isSection(value: string): value is ChangelogSection { + return (CHANGELOG_SECTIONS as readonly string[]).includes(value); +} + +function preview(text: string): string { + const line = text.split("\n")[0] ?? ""; + return line.length > 80 ? `${line.slice(0, 77)}...` : line; +} diff --git a/scripts/merge-changelog.ts b/scripts/merge-changelog.ts new file mode 100644 index 0000000..cb67046 --- /dev/null +++ b/scripts/merge-changelog.ts @@ -0,0 +1,93 @@ +/** + * Resolve a CHANGELOG.md conflict by keeping both sides' [Unreleased] entries + * (see changelog-merge.ts for when it refuses). + * + * Usage: + * just changelog-merge + * In a merge or rebase that stopped on CHANGELOG.md: resolve it from the + * index stages and `git add` it. + * node scripts/merge-changelog.ts + * As a git merge driver (`just changelog-merge-driver` sets it up). On a + * refusal it leaves the usual conflict markers and exits 1. + * + * In a merge the current side is the branch and the other is main; in a + * rebase onto main it is the other way round, so the rebase is detected. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { CHANGELOG_FILE } from "./changelog.ts"; +import { mergeChangelogs } from "./changelog-merge.ts"; + +const git = (...args: string[]) => + execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + +function rebasing(): boolean { + return ["rebase-merge", "rebase-apply"].some((dir) => + existsSync(git("rev-parse", "--git-path", dir).trim()), + ); +} + +/** The branch and main sides, given git's current and other. */ +function sides(current: string, other: string): { branch: string; main: string } { + return rebasing() ? { branch: other, main: current } : { branch: current, main: other }; +} + +const args = process.argv.slice(2); + +if (args.length === 3) { + const [basePath = "", currentPath = "", otherPath = ""] = args; + const base = readFileSync(basePath, "utf8"); + const current = readFileSync(currentPath, "utf8"); + const other = readFileSync(otherPath, "utf8"); + const result = mergeChangelogs({ base, ...sides(current, other) }); + if (result.ok) { + writeFileSync(currentPath, result.text); + process.exit(0); + } + process.stderr.write(`${CHANGELOG_FILE}: ${result.reason} Resolve it by hand.\n`); + try { + // Leave the usual conflict markers, as git's own merge would. + execFileSync("git", [ + "merge-file", + "-L", + "current", + "-L", + "base", + "-L", + "other", + currentPath, + basePath, + otherPath, + ]); + } catch { + // merge-file exits non-zero when it wrote conflicts, which is the point. + } + process.exit(1); +} + +if (args.length === 0 || (args.length === 1 && args[0] === "--git")) { + const stage = (n: 1 | 2 | 3) => { + try { + return git("show", `:${n}:${CHANGELOG_FILE}`); + } catch { + return undefined; + } + }; + const [base, current, other] = [stage(1), stage(2), stage(3)]; + if (base === undefined || current === undefined || other === undefined) { + process.stderr.write(`${CHANGELOG_FILE} is not in conflict (or was added on both sides).\n`); + process.exit(1); + } + const result = mergeChangelogs({ base, ...sides(current, other) }); + if (!result.ok) { + process.stderr.write(`${CHANGELOG_FILE}: ${result.reason} Resolve it by hand.\n`); + process.exit(1); + } + writeFileSync(CHANGELOG_FILE, result.text); + git("add", CHANGELOG_FILE); + process.stdout.write(`${CHANGELOG_FILE}: kept both sides' [Unreleased] entries.\n`); + process.exit(0); +} + +process.stderr.write("Usage: node scripts/merge-changelog.ts [--git] | \n"); +process.exit(2);