Artifact 8a9349ab53f6eeb1aea4af9f5279b2a4c8bd1d9770dad8c2ef0306a137f223e3:
- File
docs/superpowers/specs/2026-04-30-cross-repo-release-automation-design.md
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 25901) [annotate] [blame] [check-ins using]
Cross-repo release automation — design spec
- Date: 2026-04-30
- Status: Draft, pending implementation
- Scope: libfossil, edgesync, bones — three Go modules with a dependency chain
- Author: Dan Mestas
Background
Three Go modules form a dependency chain:
libfossil ──> edgesync ──> bones
bones also imports libfossil directly (a diamond), but this design treats the chain as linear and accepts that bones receives libfossil updates only when edgesync re-releases. See "Decisions log" for rationale.
The most recent cascade (libfossil v0.4.5 → edgesync v0.0.7 → bones v0.3.3, all 2026-04-30) exposed the manual cost of the chain:
- libfossil tagged v0.4.5 with a sync bug fix.
- ~3 days passed before edgesync was bumped + tagged v0.0.7 (no notification mechanism).
- bones then required two separate PRs (#85, #86) to bump libfossil and edgesync — one because the dependency graph fans in, and one because there's no consolidation logic.
- Each repo's tag annotation was inconsistent: bones v0.3.3 had hand-written multi-line release notes; libfossil/edgesync had bare tags.
- A pre-existing rule in project memory ("run CI locally every time after you pr") was not catching CI-only failures because there's no enforcement and no single command that mirrors what GitHub Actions runs.
Pre-existing CI backlog (deferred 2026-04-26, see project_pending_ci_improvements.md): paths-ignore for docs PRs, -race, SDK-drift check, DST seed sweep. Not in scope here; tracked separately.
Goals
- Reduce upstream-tag → downstream-PR latency from days to minutes.
- Eliminate the "two PRs to bones per cascade" failure mode.
- Replace the discipline-based "run CI locally" rule with a fast, automatic pre-push check that catches obvious breakage.
- Provide one command (
make ci) per repo that runs the exact CI workflow, so contributors and the maintainer can verify before pushing. - Standardize release notes across all three repos (consistent quality, less manual annotation work).
- Provide a
make release VERSION=...helper that validates and tags from a template.
Non-goals
- Auto-merging PRs. Maintainer reviews every PR per
~/.claude/CLAUDE.mdpolicy. - Renovate/Dependabot. The repository_dispatch chain replaces it; Dependabot's polling adds latency without benefit for first-party deps.
- Monorepo migration.
- Cross-repo CI (running edgesync's tests on a libfossil PR). Out of scope; possible follow-up.
- The deferred CI backlog (paths-ignore, race, drift, DST sweep) — separate PR.
Architecture overview
End-state of the cascade:
libfossil tag (e.g. v0.4.6)
│
│ release.yml fires:
│ - gh release create --generate-notes
│ - gh api repos/danmestas/EdgeSync/dispatches (event_type=upstream-bump)
▼
edgesync
│ bump-upstream.yml fires:
│ - go get [email protected] && go mod tidy && go build ./...
│ - opens PR "chore: bump libfossil → vX.Y.Z" on branch chore/bump-libfossil-vX.Y.Z
│
│ maintainer reviews, CI passes, merges
│ maintainer runs: make release VERSION=v0.0.8
│
│ release.yml fires:
│ - goreleaser publishes GitHub release with auto-changelog
│ - gh api bones/dispatches
▼
bones
│ bump-upstream.yml fires:
│ - go get [email protected] && go mod tidy
│ - libfossil bump comes along automatically via Go MVS (edgesync's go.sum pins it)
│ - opens single PR "chore: bump EdgeSync → vA.B.C"
│
│ maintainer reviews, merges, tags, ships goreleaser → Homebrew
Each repo additionally gains:
make ci— verbatim mirror of that repo's GitHub Actions test workflow.make ci-fast— vet + build + short tests, sub-60s, used by pre-push hook..git/hooks/pre-push(auto-installed viamake setup-hooks) — runsmake ci-fast, blocks push on failure.make release VERSION=vX.Y.Z— validates clean tree, runsmake ci, opens$EDITORwith auto-populated tag annotation template, creates annotated tag locally. Push is intentionally manual..github/RELEASE_TEMPLATE.md— boilerplate filled in bymake release.- GoReleaser changelog config (edgesync, bones) — auto-grouped release notes.
Per-repo components
libfossil (top of chain — currently has no release pipeline)
Deviation from PR #1/#2 (decided 2026-04-30, option A): libfossil's existing
.githooks/pre-commitis comprehensive (~45s — modernc + ncruces + vet + otel + build + SDK drift) and exceeds whatmake ci-fastwould do. Therefore PR #3 does NOT add apre-pushhook or aci-fasttarget. The existing pre-commit is libfossil's sole local gate;make ciis for manual full-CI verification before tagging.
New files:
.github/workflows/release.yml(PR #6) — triggers onpush: tags: [v*]. Two steps:gh release create $TAG --generate-notes --verify-tagand the dispatch step to edgesync.Makefiletargets (PR #3, merged):ci,ci-default,ci-ncruces,ci-otel-target,release. (Noci-fastper the deviation above.).github/RELEASE_TEMPLATE.md(PR #3, merged).
Existing — unchanged: .github/workflows/test.yml, .githooks/pre-commit, setup-hooks Makefile target, test/test-drivers/test-otel Makefile targets (the dev-friendly fast-path; intentionally distinct from the new ci-* targets which mirror CI verbatim).
No goreleaser: libfossil is a library — no binaries are published. gh release create --generate-notes provides auto-changelog and source tarballs without 50 lines of goreleaser config. Revisit if cmd/libfossil ever ships as a published binary.
Multi-module note: libfossil has 3 sub-modules with their own go.mod (db/driver/modernc/, db/driver/ncruces/, observer/otel/). The new ci-default/ci-ncruces/ci-otel-target targets handle this via explicit cd <subdir> && go test ./... steps. If a future ci-fast is ever added, it must do the same — go test ./... from repo root would silently skip the sub-modules.
Secret needed: DISPATCH_PAT — fine-grained PAT, repo access scoped to danmestas/EdgeSync only, permission Contents: Read and write (for PR #6).
edgesync (middle — receives + dispatches)
Naming note: the GitHub repo is
danmestas/EdgeSync(mixed case, matchesgo.modimport pathgithub.com/danmestas/EdgeSync). Allgh api repos/danmestas/EdgeSync/...calls and${{ github.event.repository.name }}outputs use this canonical case. Local clone path~/projects/edgesyncis lowercase but irrelevant to the workflow.
New files:
.github/workflows/bump-upstream.yml— receiver listening forrepository_dispatch: types: [upstream-bump]plusworkflow_dispatchmanual fallback.- Same Makefile + hook + template files as libfossil.
Modified files:
.github/workflows/release.yml— append a dispatch step targeting bones..goreleaser.yml— addchangelog: { use: github, sort: asc, groups: [...] }config.
Secret needed: DISPATCH_PAT — fine-grained PAT, repo access scoped to danmestas/bones only, Contents: Read and write.
EXPECTED_REPO in receiver workflow: libfossil (matches ${{ github.event.repository.name }} from libfossil's release.yml). Receiver no-ops on any other dispatch.
bones (leaf — receives only)
New files:
.github/workflows/bump-upstream.yml— receiver only, no sender.- Same Makefile + hook + template files.
Modified files:
.goreleaser.yml— add changelog config..github/workflows/release.yml— no changes (bones doesn't dispatch).
Secret needed: none beyond existing.
EXPECTED_REPO in receiver workflow: EdgeSync.
Dispatch protocol
Event name: upstream-bump (single event, receiver discriminates on payload).
Payload schema (client_payload):
| Field | Example | Purpose |
|---|---|---|
repo |
libfossil |
Short name; receiver checks against EXPECTED_REPO env var |
module_path |
github.com/danmestas/go-libfossil |
Full Go module path used by go get |
version |
v0.4.6 |
Tag string with leading v; validated against ^v\d+\.\d+\.\d+(-.+)?$ |
release_url |
https://github.com/danmestas/go-libfossil/releases/tag/v0.4.6 |
Embedded in PR body for one-click reviewer access |
tag_sha |
2baaced... |
Audit trail in case of force-moved tags |
actor |
danmestas |
Who pushed the tag |
Validation: receiver workflow fails loud (::error::) on:
reponot matchingEXPECTED_REPO→ exits 0 (silent ignore for cross-routing safety).module_pathnot matchingEXPECTED_MODULE→ exits 1.versionfailing regex → exits 1.
Sender step pattern (in libfossil/edgesync release.yml):
- name: Dispatch downstream bump
env:
GH_TOKEN: ${{ secrets.DISPATCH_PAT }}
DOWNSTREAM: EdgeSync # in libfossil; "bones" in edgesync
MODULE_PATH: github.com/danmestas/go-libfossil # the upstream's own module path; in edgesync: github.com/danmestas/EdgeSync
run: |
gh api -X POST "repos/danmestas/${DOWNSTREAM}/dispatches" \
-f event_type=upstream-bump \
-f client_payload[repo]=${{ github.event.repository.name }} \
-f client_payload[module_path]=${MODULE_PATH} \
-f client_payload[version]=${{ github.ref_name }} \
-f client_payload[release_url]=${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }} \
-f client_payload[tag_sha]=${{ github.sha }} \
-f client_payload[actor]=${{ github.actor }}
DOWNSTREAM and MODULE_PATH are hard-coded per repo:
- libfossil's release.yml:
DOWNSTREAM=EdgeSync,MODULE_PATH=github.com/danmestas/go-libfossil - edgesync's release.yml:
DOWNSTREAM=bones,MODULE_PATH=github.com/danmestas/EdgeSync
Idempotency: Receiver checks for an existing open PR on branch chore/bump-${repo}-${version} before doing work. Re-firing the same dispatch is a no-op.
Failure recovery:
| Failure | Symptom | Recovery |
|---|---|---|
DISPATCH_PAT expired |
Sender's dispatch step fails; tag + release still created | Rotate PAT, manually fire via gh workflow run bump-upstream.yml |
| Receiver workflow broken | No PR opens after upstream tag | Manually bump go.mod, fix workflow on a separate PR |
go get fails (module proxy lag) |
Receiver auto-retries 3× with 15s backoff; if still failing, workflow fails in "Bump module" step | Re-fire via workflow_dispatch after a few minutes (most cases self-recover) |
| Bad tag pushed (force-move) | tag_sha in PR body shows the old SHA |
Reviewer notices in PR body before merging |
Receiver workflow (bump-upstream.yml)
Same shape in edgesync and bones. ~75 lines including workflow_dispatch fallback.
name: Bump upstream
on:
repository_dispatch:
types: [upstream-bump]
workflow_dispatch:
inputs:
repo: { required: true, type: string }
module_path: { required: true, type: string }
version: { required: true, type: string }
release_url: { required: false, type: string }
tag_sha: { required: false, type: string }
actor: { required: false, type: string }
permissions:
contents: write
pull-requests: write
jobs:
bump:
runs-on: ubuntu-latest
env:
EXPECTED_REPO: libfossil # bones uses "EdgeSync"
EXPECTED_MODULE: github.com/danmestas/go-libfossil # bones: github.com/danmestas/EdgeSync
steps:
- name: Resolve inputs
id: in
run: |
REPO="${{ github.event.client_payload.repo || inputs.repo }}"
MODULE_PATH="${{ github.event.client_payload.module_path || inputs.module_path }}"
VERSION="${{ github.event.client_payload.version || inputs.version }}"
RELEASE_URL="${{ github.event.client_payload.release_url || inputs.release_url }}"
TAG_SHA="${{ github.event.client_payload.tag_sha || inputs.tag_sha }}"
if [[ "$REPO" != "$EXPECTED_REPO" ]]; then
echo "::notice::Ignoring dispatch for $REPO (expected $EXPECTED_REPO)"
exit 0
fi
[[ "$MODULE_PATH" == "$EXPECTED_MODULE" ]] || { echo "::error::module_path mismatch: got $MODULE_PATH, expected $EXPECTED_MODULE"; exit 1; }
[[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]] || { echo "::error::bad version: $VERSION"; exit 1; }
{
echo "repo=$REPO"
echo "module=$MODULE_PATH"
echo "version=$VERSION"
echo "release_url=$RELEASE_URL"
echo "tag_sha=$TAG_SHA"
echo "branch=chore/bump-${REPO}-${VERSION}"
} >> "$GITHUB_OUTPUT"
- name: Idempotency check
id: idem
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh pr list --repo "${{ github.repository }}" --head "${{ steps.in.outputs.branch }}" --state open --json number -q '.[0].number' | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::PR already open for ${{ steps.in.outputs.branch }}; skipping"
fi
- if: steps.idem.outputs.exists != 'true'
uses: actions/checkout@v4
with: { fetch-depth: 0 }
- if: steps.idem.outputs.exists != 'true'
uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- if: steps.idem.outputs.exists != 'true'
name: Bump module
id: bump
run: |
git switch -c "${{ steps.in.outputs.branch }}"
# GOPROXY can lag the upstream tag push; retry before giving up.
for i in 1 2 3; do
if go get "${{ steps.in.outputs.module }}@${{ steps.in.outputs.version }}"; then
break
fi
[ "$i" -lt 3 ] && sleep 15 || { echo "::error::go get failed after 3 attempts"; exit 1; }
done
go mod tidy
if git diff --quiet go.mod go.sum; then
echo "::notice::No-op bump — version already pinned"
echo "noop=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.idem.outputs.exists != 'true' && steps.bump.outputs.noop != 'true'
name: Verify build
run: go build ./...
- if: steps.idem.outputs.exists != 'true' && steps.bump.outputs.noop != 'true'
name: Commit + push + open PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add go.mod go.sum
git commit -m "chore: bump ${{ steps.in.outputs.repo }} → ${{ steps.in.outputs.version }}"
git push -u origin "${{ steps.in.outputs.branch }}"
gh pr create \
--title "chore: bump ${{ steps.in.outputs.repo }} → ${{ steps.in.outputs.version }}" \
--body "Auto-bump from upstream release.
- Module: \`${{ steps.in.outputs.module }}\`
- Version: \`${{ steps.in.outputs.version }}\`
- Upstream release: ${{ steps.in.outputs.release_url }}
- Tag SHA: \`${{ steps.in.outputs.tag_sha }}\`
CI must pass before merge. Review go.sum diff for transitive surprises."
Local dev surface
make ci — per-repo verbatim mirrors
libfossil (mirrors test.yml):
.PHONY: ci ci-default ci-ncruces ci-otel ci-fast
ci: ci-default ci-ncruces ci-otel
ci-default:
GOWORK=off go test $(shell GOWORK=off go list ./... | grep -v '/dst') -count=1 -timeout=120s
GOWORK=off go test ./dst/... -count=1 -timeout=300s
cd db/driver/modernc && GOWORK=off go test ./... -count=1
GOWORK=off go vet ./...
GOWORK=off go build ./cmd/libfossil/
ci-ncruces:
GOWORK=off go test -tags test_ncruces $(shell GOWORK=off go list ./... | grep -v '/dst' | grep -v 'cmd/libfossil') -count=1 -timeout=120s
GOWORK=off go test -tags test_ncruces ./dst/... -count=1 -timeout=300s
cd db/driver/ncruces && GOWORK=off go test ./... -count=1
ci-otel:
cd observer/otel && GOWORK=off go test ./... -count=1
edgesync (mirrors ci.yml + test.yml): targets ci-vet, ci-leaf-bridge, ci-cmd, ci-sim. Exact commands extracted verbatim from each step's run: block.
bones (mirrors the bones-side portion of ci.yml; cross-repo leaf-binary integration is CI-only):
ci:
$(MAKE) check
go build -tags=otel ./...
go test -tags=otel -short ./... -count=1
make ci-fast — pre-push subset
ci-fast:
go vet ./...
go build ./...
go test -short -count=1 -timeout=30s ./...
No -race, no -tags=otel, no -tags test_ncruces, no driver subdirs. ~30-60s. Catches build break, vet errors, obvious test failures. Misses: race conditions, otel-tagged code, ncruces-tagged code, DST. Those are make ci territory.
scripts/install-hooks.sh
#!/usr/bin/env bash
set -euo pipefail
HOOK_DIR="$(git rev-parse --git-dir)/hooks"
mkdir -p "$HOOK_DIR"
cat > "$HOOK_DIR/pre-push" <<'EOF'
#!/usr/bin/env bash
set -e
echo "[pre-push] running make ci-fast..."
make ci-fast
EOF
chmod +x "$HOOK_DIR/pre-push"
echo "Installed pre-push hook → make ci-fast"
Wired into existing make setup-hooks. Bypass with git push --no-verify only when intentional.
make release VERSION=...
.PHONY: release
release:
@test -n "$(VERSION)" || { echo "VERSION=vX.Y.Z required"; exit 1; }
@echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-.+)?$$' || { echo "bad version format"; exit 1; }
@git diff --quiet || { echo "tree dirty; commit or stash first"; exit 1; }
@git fetch origin --tags
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "tag $(VERSION) already exists"; exit 1; fi
@$(MAKE) ci
@PREV=$$(git describe --tags --abbrev=0 2>/dev/null || echo ""); \
TMPL=.github/RELEASE_TEMPLATE.md; \
TMP=$$(mktemp); \
{ echo "Release $(VERSION)"; echo; \
[ -f $$TMPL ] && { cat $$TMPL; echo; }; \
echo "## Changes"; \
if [ -n "$$PREV" ]; then git log --oneline $$PREV..HEAD; else git log --oneline; fi; } > $$TMP; \
$${EDITOR:-vi} $$TMP; \
git tag -a "$(VERSION)" -F $$TMP; \
rm $$TMP
@echo "Tag created locally. Run: git push origin $(VERSION)"
Two-phase intentional: make release creates tag locally only. Push is manual (git push origin $VERSION) so a fat-fingered version doesn't immediately fan out to remote + downstream.
.github/RELEASE_TEMPLATE.md
<!-- Edit before tagging. Auto-populated change list appended below. -->
## Highlights
-
## Breaking changes
None.
## Upgrade notes
-
GoReleaser changelog (edgesync, bones)
changelog:
use: github
sort: asc
groups:
- title: 'Features'
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: 'Bug fixes'
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: 'Other'
order: 999
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- '^ci:'
libfossil uses gh release create --generate-notes instead, which produces equivalent grouping from PR labels + commit prefixes.
Rollout — 6 PRs, sequential
| # | Repo | Title | Adds | Risk |
|---|---|---|---|---|
| 1 | bones | chore: add make ci, ci-fast, release helper, pre-push hook |
Makefile targets, scripts/install-hooks.sh, .github/RELEASE_TEMPLATE.md |
Low |
| 2 | edgesync | same as #1 | same | Low |
| 3 | libfossil | same as #1 (no goreleaser config exists yet to touch) | same | Low |
| 4 | bones | feat(ci): add bump-upstream workflow + goreleaser changelog |
bump-upstream.yml, goreleaser changelog |
Medium |
| 5 | edgesync | feat(ci): add bump-upstream workflow, dispatch on tag, goreleaser changelog |
bump-upstream.yml + dispatch step in release.yml + goreleaser changelog |
Medium-high |
| 6 | libfossil | feat(ci): add release workflow with downstream dispatch |
new release.yml |
Medium |
Order rationale:
- PRs 1-3 (local dev hygiene) are pure adds with no remote behavior change. Land them first to have
make ciand pre-push hooks across all repos before touching dispatch wiring. - PR 4 lets bones be tested as a receiver in isolation via manual
gh apifire. - PR 5 brings up both sides of the dispatch in one repo; tests both directions in one cycle (manual fire to receiver, real tag to test sender).
- PR 6 closes the chain. After this PR merges and a real libfossil release fires, the cascade is alive.
PAT setup
One-time, before PR #4 merges:
DISPATCH_PATfor libfossil — fine-grained PAT, repo access:danmestas/EdgeSynconly, permissions:Contents: Read and write. Stored in libfossil repo secrets.DISPATCH_PATfor edgesync — fine-grained PAT, repo access:danmestas/bonesonly, same perms. Stored indanmestas/EdgeSyncrepo secrets.
1-year expiry; calendar reminder for rotation. Existing HOMEBREW_PAT_TOKEN is too broad to reuse.
Testing strategy
Per-PR local validation
PRs 1-3 (Makefile/hooks):
make ciexits 0 on a clean main.make ci-fastruns in <60s.make setup-hooksinstallspre-push; verify withcat .git/hooks/pre-pushandgit push --dry-run.make release VERSION=v9.9.9-testvalidates and tags locally;git tag -d v9.9.9-testto clean up.
Receiver workflow validation (PR #4, then again in #5)
Manual fire after PR merge:
gh api -X POST repos/danmestas/bones/dispatches \
-f event_type=upstream-bump \
-f client_payload[repo]=EdgeSync \
-f client_payload[module_path]=github.com/danmestas/EdgeSync \
-f client_payload[version]=v0.0.7 \
-f client_payload[release_url]=https://github.com/danmestas/EdgeSync/releases/tag/v0.0.7 \
-f client_payload[tag_sha]=$(gh api repos/danmestas/EdgeSync/git/refs/tags/v0.0.7 -q .object.sha) \
-f client_payload[actor]=danmestas
Expected outcomes:
| Scenario | Expected |
|---|---|
| First fire with already-pinned version | noop=true, no PR created |
| First fire with new version | PR opens on chore/bump-EdgeSync-vX.Y.Z |
| Re-fire same version | "PR already open" notice, no second PR |
Bad version (vbogus) |
Workflow fails with ::error::bad version |
Wrong repo (repo=foobar) |
Workflow logs notice and exits 0 silently |
workflow_dispatch with same inputs |
Same behavior as repository_dispatch |
End-to-end cascade validation (PR #5, PR #6)
After PR #5:
- Tag edgesync with
v0.0.8-rc1. - Watch
release.ymlfire goreleaser + dispatch step. - Watch bones receive dispatch and open PR
chore/bump-EdgeSync-v0.0.8-rc1. - Close PR (don't merge); delete
v0.0.8-rc1tag from origin and the prerelease GitHub release.
After PR #6:
- Tag libfossil with
v0.4.6-rc1. - Watch libfossil
release.ymlfiregh release create+ dispatch. - Watch edgesync open PR.
- Don't merge edgesync PR; close it; delete the rc tag and prerelease release.
-rc1 suffix matters: changelog/release configs treat non-numeric suffixes as prereleases, so rc tags don't pollute the main release stream.
Decisions log
| Decision | Choice | Rationale |
|---|---|---|
| Scope | Bucket C: local dev hygiene + dispatch automation + release ergonomics | Closes the loop on consistency; deferred CI backlog handled separately |
| Dispatch mechanism | repository_dispatch (not Dependabot) |
Tag-triggered, seconds latency, customizable PR body with upstream changelog link |
| Pre-push enforcement | Fast subset (vet+build+short tests, ~60s); full mirror behind make ci |
Hard-block on 7-min hook would train --no-verify bypass; fast hook catches cheap mistakes |
| Dispatch graph | Linear (libfossil→edgesync→bones); libfossil does NOT dispatch to bones directly | Avoids fan-in to bones (the original "two PRs" pain); accepts that bones needs an edgesync release to pick up libfossil-only updates |
| Rollout | 6 PRs (3 hygiene + 3 dispatch) | Per CLAUDE.md, every PR is reviewed manually — smaller diffs are easier to review |
| libfossil release pipeline | gh release create --generate-notes (not goreleaser) |
Library, no binaries; goreleaser would be 50 lines of YAGNI |
| Spec location | libfossil/docs/superpowers/specs/ |
Matches prior 2026-04-25-docs-site-cf-deploy-design.md; libfossil is the source of the chain |
| Bot identity | github-actions[bot] (uid 41898282) |
GitHub stock identity; complies with "never attribute Claude/AI" rule |
Open questions / future work
- Cross-repo CI: should an edgesync PR run libfossil's tests against its current pin? Possible follow-up; out of scope here.
- Reusable workflow:
bump-upstream.ymlis ~95% identical between edgesync and bones. Could move to a fourth.githubrepo as a reusable workflow once the pattern stabilizes. Premature for a 3-repo cascade. - Auto-merge tier: if cascade reliability is high after a few real cascades, consider an
automergelabel that lets bot-authored chore-bump PRs merge on green CI. Requires branch protection + status checks. Not in this design. - Deferred CI improvements: paths-ignore for docs PRs,
-racein libfossil, SDK-drift check in libfossil, DST seed sweep. Tracked inproject_pending_ci_improvements.md. Could fold into PRs 1-3 but bloats their scope; recommend keeping separate. - Diamond dep edge case: linear-only means bones lags libfossil if edgesync doesn't release. If a libfossil-only fix becomes urgent for bones, manual override is
gh workflow run bump-upstream.yml -f repo=libfossil ...after temporarily relaxing theEXPECTED_REPOcheck, or a one-off manual go.mod bump. Acceptable today; revisit if it happens more than annually.