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
16 changes: 8 additions & 8 deletions .autoducks/.installed.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
"schemaVersion": 1,
"source_repo": "deepducks/autoducks",
"channel": "stable",
"ref": "0a09e02a45b55e8b0b9ad460a466a123be81fcee",
"sha": "0a09e02a45b55e8b0b9ad460a466a123be81fcee",
"version": "0.5.9",
"installed_at": "2026-08-04T16:12:17Z",
"installed_by": "autoducks-update.yml#30927964151",
"ref": "7e80e724b9269033ab288ff6379b8fea9038fd7f",
"sha": "7e80e724b9269033ab288ff6379b8fea9038fd7f",
"version": "0.5.10",
"installed_at": "2026-08-04T17:10:49Z",
"installed_by": "autoducks-update.yml#30932673290",
"previous": {
"ref": "4ac745e52ee271fff7ef8b677cfb875c5572ae44",
"sha": "4ac745e52ee271fff7ef8b677cfb875c5572ae44",
"version": "0.5.8"
"ref": "0a09e02a45b55e8b0b9ad460a466a123be81fcee",
"sha": "0a09e02a45b55e8b0b9ad460a466a123be81fcee",
"version": "0.5.9"
}
}
5 changes: 5 additions & 0 deletions .autoducks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [0.5.10] - 2026-08-04

### Fixed
- fix(update): install onto the default branch, the one that actually executes (#1185)

## [0.5.9] - 2026-08-04

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion .autoducks/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.9
0.5.10
41 changes: 33 additions & 8 deletions .autoducks/agents/update/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ PIN="$AUTODUCKS_UPDATE_PIN"
MODE="${MODE:-$AUTODUCKS_UPDATE_MODE}"
[[ "$MODE" =~ ^(pr|commit|off)$ ]] || MODE="$AUTODUCKS_UPDATE_MODE"

# ── Delivery target ──────────────────────────────────────────────────────
# Where the updated machinery is installed. This is the repository's default
# branch, NOT AUTODUCKS_BASE_BRANCH, and the distinction is load-bearing.
#
# A scheduled or dispatched run executes the workflow files and .autoducks/
# scripts from the default branch — that is what `actions/checkout@v4` with no
# `ref:` gives every lane. So the default branch is the only place an install
# takes effect. AUTODUCKS_BASE_BRANCH means something else entirely: the branch
# the pipeline cuts feature/fix branches from.
#
# For most repos the two are the same branch and nothing changes. Where they
# differ the old behaviour was silently useless: deepducks/swanapse cuts from
# `master` but is served from `ggondim`, so v0.5.8 and v0.5.9 both landed on
# `master` while every run kept executing v0.5.2 off `ggondim`. Two consecutive
# releases reported success and changed nothing.
#
# The fallback to AUTODUCKS_BASE_BRANCH covers only an unreachable host; it
# preserves the old behaviour rather than aborting a cycle over a transient API
# failure, and says so out loud.
UPDATE_TARGET_BRANCH="$(git::default_branch)"
if [[ -z "$UPDATE_TARGET_BRANCH" ]]; then
UPDATE_TARGET_BRANCH="$AUTODUCKS_BASE_BRANCH"
echo "::warning::update: could not resolve the default branch of $REPO — falling back to base_branch '$AUTODUCKS_BASE_BRANCH'. If the two differ, this update will land where it does not execute." >&2
fi

UPDATE_FAILURE_MARKER="<!-- autoducks:update-failure -->"
# Identifies the "a newer version is available" note already posted on an open
# update PR, per target SHA, so a weekly cycle does not repeat itself.
Expand Down Expand Up @@ -153,7 +178,7 @@ update::preflight() {
fi

local open_prs existing
open_prs="$(git::list_open_prs "$AUTODUCKS_BASE_BRANCH" 2>/dev/null || echo '[]')"
open_prs="$(git::list_open_prs "$UPDATE_TARGET_BRANCH" 2>/dev/null || echo '[]')"
existing="$(printf '%s' "$open_prs" | jq -r '[.[] | select(.headRefName | startswith("autoducks/update-"))] | .[0].number // empty')"
if [[ -n "$existing" ]]; then
printf 'existing-pr\t%s' "$existing"
Expand Down Expand Up @@ -683,7 +708,7 @@ ${UPDATE_AVAILABLE_MARKER}${target_sha}" || true
exit 0
fi

update::apply_branch "$AUTODUCKS_BASE_BRANCH" "$branch" "$target_sha" "$CHANNEL"
update::apply_branch "$UPDATE_TARGET_BRANCH" "$branch" "$target_sha" "$CHANNEL"

local migration_report; migration_report="$(mktemp)"
if ! update::run_migrations "$installed_version" "$target_version" ".autoducks/migrations" "$migration_report"; then
Expand Down Expand Up @@ -779,23 +804,23 @@ No PR was opened and no commit was made; the update branch was discarded."
local _why="a major bump"
[[ "$bump_kind" != "major" && "$has_breaking" == "1" ]] && _why="a breaking changelog entry"
[[ "$bump_kind" != "major" && "$has_breaking" != "1" ]] && _why="local machinery drift (or drift that could not be evaluated)"
echo "::notice::update: mode is 'commit' but this update carries $_why — opening a PR instead of pushing to $AUTODUCKS_BASE_BRANCH." >&2
echo "::notice::update: mode is 'commit' but this update carries $_why — opening a PR instead of pushing to $UPDATE_TARGET_BRANCH." >&2
else
update::deliver_commit "$branch" "$AUTODUCKS_BASE_BRANCH" "$target_version"
git push origin "HEAD:refs/heads/$AUTODUCKS_BASE_BRANCH"
update::deliver_commit "$branch" "$UPDATE_TARGET_BRANCH" "$target_version"
git push origin "HEAD:refs/heads/$UPDATE_TARGET_BRANCH"
# deliver_commit pushes the branch because the PR path needs it; this path
# does not. Without this the branch outlives the cycle — apply_branch clears
# a stale ref on the next run, so it self-healed, but a commit-mode repo
# carried one visible orphan branch between cycles.
git::delete_branch "$branch" 2>/dev/null || true
update::report_success "Pushed \`$title\` directly to \`$AUTODUCKS_BASE_BRANCH\` (mode: commit)."
update::report_success "Pushed \`$title\` directly to \`$UPDATE_TARGET_BRANCH\` (mode: commit)."
exit 0
fi
fi

update::deliver_commit "$branch" "$AUTODUCKS_BASE_BRANCH" "$target_version"
update::deliver_commit "$branch" "$UPDATE_TARGET_BRANCH" "$target_version"
local pr_number
pr_number="$(git::create_pr "$branch" "$AUTODUCKS_BASE_BRANCH" "$title" "$body" "false")"
pr_number="$(git::create_pr "$branch" "$UPDATE_TARGET_BRANCH" "$title" "$body" "false")"

its::add_label "$pr_number" "Autoducks:update" || true
if [[ "$bump_kind" == "major" || "$has_breaking" == "1" ]]; then
Expand Down
2 changes: 2 additions & 0 deletions .autoducks/design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ Two sources, in this order, and **never a literal** (#1181):

`main` was previously hardcoded as the fallback in `autoducks-commit-lint.yml` and `autoducks-developer.yml`, so a repo on `master` got a push trigger that never fired and a checkout of a ref that did not exist — both silently, because a trigger that does not fire is indistinguishable from one with nothing to report.

**The Update lane installs onto the default branch, not `base_branch`.** Every lane runs `actions/checkout@v4` with no `ref:` on a scheduled or dispatched trigger, so the workflow files and `.autoducks/` scripts that execute are the default branch's copy. That makes the default branch the only place an install takes effect, which is a fact about the host rather than a preference. `base_branch` answers a different question — where the pipeline cuts `feature/`/`fix/` branches from — and the two are the same branch in most repos. Where they differ, delivering to `base_branch` produced an update that reported success and changed nothing: `deepducks/swanapse` cuts from `master` but is served from `ggondim`, and took two consecutive releases on `master` while every run kept executing the older machinery on `ggondim`. `update/run.sh` resolves `UPDATE_TARGET_BRANCH` from [`git::default_branch`](../providers/git/interface.sh), falling back to `base_branch` with a warning only when the host cannot answer.

**`AUTODUCKS_BASE_BRANCH` carries source 1 only, and may be empty.** `load-config.sh` exports the configured value verbatim and does not resolve step 2; the caller does, because the caller knows whether it can reach the host. [`sync-child-gitlinks.sh`](../core/orchestration/sync-child-gitlinks.sh) is the reference shape: config, then the host API, then a warning and a clean exit rather than acting on a branch named `""`.

Resolving step 2 inside `load-config.sh` is a tempting simplification and a mistake. The only source available there without a token is `origin/HEAD`, a local ref that can be stale or absent, and populating the variable from it silently preempts the authoritative answer the caller was about to fetch.
Expand Down
20 changes: 20 additions & 0 deletions .autoducks/providers/git/github/default-branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail

# git::default_branch → the repository's default branch name, empty when the
# host cannot answer (offline, token refused, repo gone).
#
# Distinct from AUTODUCKS_BASE_BRANCH on purpose. That key says where the
# pipeline cuts branches from; this says which branch the host actually serves
# as HEAD — and therefore which copy of .github/workflows/ and .autoducks/ a
# scheduled or dispatched run executes. A repo can legitimately set them to
# different branches (see the update agent's delivery target).
git::default_branch() {
gh api "repos/$REPO" --jq '.default_branch' 2>/dev/null || true
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
case "${1:-}" in
--help) echo "Usage: git::default_branch"; echo " Print the repository's default branch (empty if unresolvable)"; echo " Requires: REPO env var"; exit 0 ;;
esac
fi
6 changes: 6 additions & 0 deletions .autoducks/providers/git/interface.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ done
#
# git::create_branch(base, name)
# git::branch_exists(name) → exit code 0/1
# git::default_branch() → branch name, or empty
# The branch the host serves as HEAD, which is the copy of
# .github/workflows/ and .autoducks/ a scheduled or dispatched run
# executes. Not AUTODUCKS_BASE_BRANCH, which says where the pipeline cuts
# from; a repo may legitimately set the two to different branches. Empty
# when the host cannot answer — callers decide whether that is fatal.
# git::create_pr(head, base, title, body, draft?) → pr_number
# git::merge_pr(pr_number, when?) → 0 ok / 2 method-not-allowed / 1 other
# when ∈ {now, auto}, default now. `auto` asks the host to hold the merge
Expand Down
Loading