Skip to content
Merged
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
155 changes: 155 additions & 0 deletions .github/workflows/changelog-merge.yml
Original file line number Diff line number Diff line change
@@ -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="<!-- changelog-merge -->"
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}"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <list>` shows what a list matches; the runbook's "CI and CD" says how to force a rerun.
Expand Down
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <list>` 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 <list>` 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
Expand Down Expand Up @@ -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]
```

Expand All @@ -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)
Expand Down
Loading
Loading