From 580da1bc2f66e9345e9a92649a350717d29c3dd9 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 8 Sep 2026 17:46:54 +0200 Subject: [PATCH 1/4] ci: back-merge without waiting for a person to merge a PR The old workflow opened a pull request and left the merge to a human. The first one was merged with "squash", which copied main's content into develop as one ordinary commit and dropped its history. Git then had no way to know develop already held those changes, so every later back-merge tried to re-apply 236 commits whose content was already there: 67 conflicting files on a merge that should have been a formality. A back-merge carries no decision, so it no longer waits for one. The workflow fast-forwards develop when develop has nothing of its own, and otherwise makes the merge commit itself and pushes it. A pull request is still opened, but only when the work genuinely cannot be done unattended -- a real conflict, or a branch rule refusing the push -- and its body now says in capitals not to squash it. develop itself was repaired separately: main merged in with its content taken throughout, verified byte-identical to main's tree, which lost nothing because develop carried no work of its own. --- .github/workflows/backmerge.yml | 79 +++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index e47c8f7..1d4444c 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -1,8 +1,17 @@ -# After anything lands on main, keep develop in sync by opening (or refreshing) -# a pull request that merges main back into develop. +# After anything lands on main, bring develop up to it. # -# A PR rather than a direct push, so the merge is visible and CI runs on it. -# Conflicts are resolved on the PR. +# The merge happens HERE, not in a pull request a person merges. It used to be +# a PR, and that failed in a way worth remembering: the first one was merged +# with "squash", so develop received main's CONTENT as one ordinary commit but +# none of its history. Git then had no idea develop already held those changes, +# and every later back-merge tried to re-apply 236 commits whose content was +# already there -- 67 conflicting files, on a merge that should have been a +# formality. A back-merge needs no review and no human decision, so it no +# longer waits for one. +# +# A pull request is still opened, but only when the merge genuinely cannot be +# done unattended: a real conflict, or a protected branch that refuses the +# push. Those are the cases where a person is actually needed. # # While the project is pre-release the flow is inverted from the eventual one: # work happens on main, and develop follows it. Once releases start, features @@ -16,7 +25,7 @@ on: branches: [main] permissions: - contents: read + contents: write pull-requests: write concurrency: @@ -24,38 +33,80 @@ concurrency: cancel-in-progress: false jobs: - open-backmerge-pr: + back-merge: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Skip if develop already contains main + - name: Decide what is needed id: check run: | set -uo pipefail # Said plainly rather than dying on `fatal: Not a valid object name`, # which is what the first run did before develop existed. if ! git rev-parse --verify --quiet origin/develop >/dev/null; then - echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "action=none" >> "$GITHUB_OUTPUT" echo "::notice::there is no develop branch, so there is nothing to back-merge into." exit 0 fi if git merge-base --is-ancestor origin/main origin/develop; then - echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "action=none" >> "$GITHUB_OUTPUT" echo "develop already contains main; nothing to do." + elif git merge-base --is-ancestor origin/develop origin/main; then + # The healthy case: develop has nothing of its own, so this is a + # pointer move with no merge commit and no possible conflict. + echo "action=fast-forward" >> "$GITHUB_OUTPUT" + else + echo "action=merge" >> "$GITHUB_OUTPUT" + fi + + - name: Fast-forward develop to main + id: ff + if: steps.check.outputs.action == 'fast-forward' + run: | + set -uo pipefail + if git push origin "origin/main:refs/heads/develop"; then + echo "pushed=true" >> "$GITHUB_OUTPUT" + else + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::could not fast-forward develop, probably branch protection; opening a pull request instead." + fi + + - name: Merge main into develop + id: merge + if: steps.check.outputs.action == 'merge' + run: | + set -uo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B develop origin/develop + # --no-ff so the merge is always recorded as one. A back-merge that + # leaves no merge commit is exactly the failure this workflow exists + # to avoid. + if ! git merge --no-ff origin/main -m "chore: back-merge main into develop"; then + git merge --abort || true + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::main and develop conflict; opening a pull request for a person to resolve." + exit 0 + fi + if git push origin develop; then + echo "pushed=true" >> "$GITHUB_OUTPUT" else - echo "needs_pr=true" >> "$GITHUB_OUTPUT" + echo "pushed=false" >> "$GITHUB_OUTPUT" + echo "::warning::could not push the merge, probably branch protection; opening a pull request instead." fi - - name: Open or update back-merge PR - if: steps.check.outputs.needs_pr == 'true' + - name: Open or update a back-merge PR, when the merge could not be done here + if: | + steps.ff.outputs.pushed == 'false' || steps.merge.outputs.pushed == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKFLOW_NAME: ${{ github.workflow }} RUN_ID: ${{ github.run_id }} run: | + set -uo pipefail MAIN_SHA="$(git rev-parse --short origin/main)" MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)" @@ -67,13 +118,13 @@ jobs: BODY_FILE="$(mktemp)" cat > "$BODY_FILE" < Date: Tue, 8 Sep 2026 17:50:15 +0200 Subject: [PATCH 2/4] ci: let the back-merge pull request merge itself The previous commit tried to have the workflow push the merge to develop. That cannot work: `develop` is protected and refuses pushes outright -- "Changes must be made through a pull request". The attempt is on record in the run it declined. So the pull request stays; the person goes. The workflow now turns on auto-merge with the MERGE method, and GitHub merges the PR once its required checks pass. Nobody picks a method, so the squash that broke this once cannot happen by accident. Where auto-merge is unavailable the run warns and the PR body says, in capitals, not to squash it. This needed `allow_auto_merge` on the repository, which was off. Permissions drop to `contents: read`, since nothing pushes any more. --- .github/workflows/backmerge.yml | 106 ++++++++++++-------------------- 1 file changed, 38 insertions(+), 68 deletions(-) diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index 1d4444c..69f1980 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -1,17 +1,19 @@ # After anything lands on main, bring develop up to it. # -# The merge happens HERE, not in a pull request a person merges. It used to be -# a PR, and that failed in a way worth remembering: the first one was merged -# with "squash", so develop received main's CONTENT as one ordinary commit but -# none of its history. Git then had no idea develop already held those changes, -# and every later back-merge tried to re-apply 236 commits whose content was -# already there -- 67 conflicting files, on a merge that should have been a -# formality. A back-merge needs no review and no human decision, so it no -# longer waits for one. +# A pull request that merges ITSELF. `develop` is protected -- pushes to it are +# refused with "Changes must be made through a pull request" -- so the PR is not +# optional. What is optional is the person, and the person was the problem: the +# first back-merge PR was merged with "squash", which copied main's content into +# develop as one ordinary commit and dropped its history. Git then had no way to +# know develop already held those changes, so every later back-merge tried to +# re-apply 236 commits whose content was already there: 67 conflicting files on +# a merge that should have been a formality. # -# A pull request is still opened, but only when the merge genuinely cannot be -# done unattended: a real conflict, or a protected branch that refuses the -# push. Those are the cases where a person is actually needed. +# So the workflow opens the PR and turns on auto-merge with the MERGE method. +# GitHub merges it once the required checks pass, nobody chooses a method, and +# a squash cannot happen by accident. Auto-merge has to be enabled on the +# repository for this (`allow_auto_merge`); if it is off, the run says so and +# leaves the PR for a person, who must merge it with a merge commit. # # While the project is pre-release the flow is inverted from the eventual one: # work happens on main, and develop follows it. Once releases start, features @@ -25,7 +27,9 @@ on: branches: [main] permissions: - contents: write + # Read is enough: nothing here pushes. The merge is GitHub'''s, done on the + # pull request once its checks pass. + contents: read pull-requests: write concurrency: @@ -54,53 +58,12 @@ jobs: if git merge-base --is-ancestor origin/main origin/develop; then echo "action=none" >> "$GITHUB_OUTPUT" echo "develop already contains main; nothing to do." - elif git merge-base --is-ancestor origin/develop origin/main; then - # The healthy case: develop has nothing of its own, so this is a - # pointer move with no merge commit and no possible conflict. - echo "action=fast-forward" >> "$GITHUB_OUTPUT" else - echo "action=merge" >> "$GITHUB_OUTPUT" + echo "action=pr" >> "$GITHUB_OUTPUT" fi - - name: Fast-forward develop to main - id: ff - if: steps.check.outputs.action == 'fast-forward' - run: | - set -uo pipefail - if git push origin "origin/main:refs/heads/develop"; then - echo "pushed=true" >> "$GITHUB_OUTPUT" - else - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::could not fast-forward develop, probably branch protection; opening a pull request instead." - fi - - - name: Merge main into develop - id: merge - if: steps.check.outputs.action == 'merge' - run: | - set -uo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B develop origin/develop - # --no-ff so the merge is always recorded as one. A back-merge that - # leaves no merge commit is exactly the failure this workflow exists - # to avoid. - if ! git merge --no-ff origin/main -m "chore: back-merge main into develop"; then - git merge --abort || true - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::main and develop conflict; opening a pull request for a person to resolve." - exit 0 - fi - if git push origin develop; then - echo "pushed=true" >> "$GITHUB_OUTPUT" - else - echo "pushed=false" >> "$GITHUB_OUTPUT" - echo "::warning::could not push the merge, probably branch protection; opening a pull request instead." - fi - - - name: Open or update a back-merge PR, when the merge could not be done here - if: | - steps.ff.outputs.pushed == 'false' || steps.merge.outputs.pushed == 'false' + - name: Open the back-merge PR and let it merge itself + if: steps.check.outputs.action == 'pr' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKFLOW_NAME: ${{ github.workflow }} @@ -110,15 +73,10 @@ jobs: MAIN_SHA="$(git rev-parse --short origin/main)" MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)" - EXISTING="$(gh pr list --head main --base develop --state open --json number --jq '.[0].number')" - if [ -n "$EXISTING" ]; then - echo "PR #$EXISTING already open for main -> develop; it will auto-update with the new push." - exit 0 - fi - + NUMBER="$(gh pr list --head main --base develop --state open --json number --jq '.[0].number')" BODY_FILE="$(mktemp)" cat > "$BODY_FILE" < Date: Tue, 8 Sep 2026 17:54:12 +0200 Subject: [PATCH 3/4] ci: open dependabot pull requests against develop A dependency bump is ordinary work, and ordinary work enters through `develop` per Branching in CONTRIBUTING.md. Opening them against `main` put them straight onto the release branch. Both ecosystems move, npm and github-actions. The one already open against `main` stays there; Dependabot does not retarget an existing pull request. --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 480e7d6..e3b20e6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,11 @@ # want: a known advisory beats an unaudited release, which is the same # exception `scripts/dependency-age-exceptions.json` records for a human. # +# Both ecosystems target `develop`, not `main`. A dependency bump is ordinary +# work, and ordinary work enters through `develop` -- see Branching in +# CONTRIBUTING.md. Opening these against `main` put them on the release branch +# and left the back-merge to carry them the wrong way round. +# # See .agents/skills/check-dependencies/SKILL.md for the reasoning, and # AGENTS.md for how a dependency change reaches a release. @@ -18,6 +23,7 @@ updates: - package-ecosystem: npm # The workspace root: one pnpm-lock.yaml covers all four manifests. directory: / + target-branch: develop schedule: interval: weekly day: monday @@ -46,6 +52,7 @@ updates: - package-ecosystem: github-actions directory: / + target-branch: develop schedule: interval: weekly day: monday From cf5049c5ec5f2eecbdc6450852fce1fde75691e0 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Tue, 8 Sep 2026 20:12:22 +0200 Subject: [PATCH 4/4] test: keep the first job busy while the lock test needs it This raced, and CI on develop lost the race: the job could finish between the poll that confirms it is running and the command that expects to be refused, and then there was nothing to refuse. `--max-cpu 1` gives the first job a single thread over 57 seconds of audio, which widens the window by about four times. The failure now says what the first job was doing. "expected not 0" cannot tell apart the three ways this goes wrong -- the job finished early, it died, or the lock was not honoured -- and the CI run that found it recorded none of that, so the cause is still unknown. Next time it will not be. The flag reaches the detached child (transcribeChildArgs forwards --max-cpu), and that it becomes `-t 1` on the engine is asserted in resources.spec.ts. --- e2e/tests/jobs.spec.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/e2e/tests/jobs.spec.ts b/e2e/tests/jobs.spec.ts index 21068f5..90c3655 100644 --- a/e2e/tests/jobs.spec.ts +++ b/e2e/tests/jobs.spec.ts @@ -264,12 +264,19 @@ describe('ailoud background jobs', () => { expect(importMatch).not.toBeNull(); const recordingId = importMatch![1]!; - // Start the first background transcription + // Start the first background transcription. `--max-cpu 1` gives it a + // single thread, which on 57 seconds of audio leaves it working for long + // enough that the second attempt below lands while it is still holding + // the lock. Without it this raced: the job could finish between the poll + // that confirms it is running and the command that expects a refusal, + // and then there was nothing to refuse. const firstDetachResult = await sandbox.run([ 'transcribe', recordingId, '--lang', 'en', + '--max-cpu', + '1', '--detach', ]); expect(firstDetachResult.code).toBe(0); @@ -294,7 +301,20 @@ describe('ailoud background jobs', () => { 'en', '--detach', ]); - expect(secondDetachResult.code).not.toBe(0); + // On failure, say what the first job was doing. "expected not 0" alone + // cannot distinguish the three ways this goes wrong -- the job finished + // early, it died, or the lock was not honoured -- and that distinction is + // the whole question. This spec failed once in CI with none of it + // recorded. + if (secondDetachResult.code === 0) { + const holder = await readJobState(sandbox, firstJobId); + throw new Error( + `a second job was accepted while ${firstJobId} held the lock. ` + + `That job is now ${holder === null ? 'absent' : holder.state}` + + `${holder?.percent === undefined ? '' : ` at ${holder.percent}%`}. ` + + `Second command said: ${JSON.stringify(secondDetachResult.stdout.trim())}`, + ); + } // The error should name the holder (first job id) expect(secondDetachResult.stderr.toLowerCase()).toMatch(/job|running|lock|holder|refused/i); });