diff --git a/ci/experiments/01-rsync-publish/index.mjs b/ci/experiments/01-rsync-publish/index.mjs index 33e89ed..6fc838c 100644 --- a/ci/experiments/01-rsync-publish/index.mjs +++ b/ci/experiments/01-rsync-publish/index.mjs @@ -74,13 +74,15 @@ async function compose(compose_config, args) { }) } -// Hash (owner, repo_url, revision) to get an unambiguous volume name. -// Human-readable metadata is stored as Docker volume labels instead. -function worktree_volume_name(owner, repo_url, revision) { +// Hash the entire checkout definition to get an unambiguous volume name. +// Any change to any field (repo, revision, key, submodules, etc.) produces +// a different volume. Human-readable metadata is stored as Docker volume labels. +function worktree_volume_name(owner, repo_def) { + const sorted_keys = Object.keys(repo_def).sort() + const canonical = JSON.stringify(Object.fromEntries(sorted_keys.map(k => [k, repo_def[k]]))) const hash = createHash('sha256') .update(owner).update('\0') - .update(repo_url).update('\0') - .update(revision) + .update(canonical) .digest('hex') .slice(0, 24) return `ci-worktree-${hash}` @@ -90,8 +92,9 @@ function worktree_volume_name(owner, repo_url, revision) { // Step 1: Prepare base worktree volume // --------------------------------------------------------------------------- -async function prepare_worktree(owner, repo_url, revision, mutable, private_key_path) { - const volume_name = worktree_volume_name(owner, repo_url, revision) +async function prepare_worktree(owner, repo_def, private_key_path) { + const { repo_url, revision, mutable } = repo_def + const volume_name = worktree_volume_name(owner, repo_def) // Check if the volume already exists let exists = false @@ -103,7 +106,7 @@ async function prepare_worktree(owner, repo_url, revision, mutable, private_key_ } if (exists && !mutable) { - // Immutable revision — volume is permanent once created + // Immutable — volume is permanent once created console.log(`Base worktree volume ${volume_name} already exists, reusing.`) return volume_name } @@ -189,7 +192,7 @@ async function main() { const repo = checkout.repositories[0] const private_key_path = path.join(secrets_base, 'git-ssh', user, 'private', repo.key) - const worktree_volume = await prepare_worktree(user, repo.repo_url, repo.revision, repo.mutable, private_key_path) + const worktree_volume = await prepare_worktree(user, repo, private_key_path) console.log(`\nWorktree volume: ${worktree_volume}\n`) const container_name = `ci-rsync-${worktree_volume.slice('ci-worktree-'.length)}` diff --git a/ci/planning/architecture.md b/ci/planning/architecture.md index ad4290c..c8008b4 100644 --- a/ci/planning/architecture.md +++ b/ci/planning/architecture.md @@ -16,13 +16,13 @@ Images and tasks are declared separately. A task references an image by name. Mu ### Worktree layers (overlayfs) -Each run mounts a single worktree into the container, but this is composed from two separately managed Docker volumes: +A run may check out one or more repositories. Each repository produces two separately managed Docker volumes: -**Base worktree** — a named Docker volume containing a plain `git checkout` of a specific `(owner, repo, revision)`. Read-only from the task container's perspective. Whether it is reused or re-checked-out on each run depends on whether the revision is mutable (see below). +**Base worktree** — a named Docker volume containing a plain `git checkout` of a specific repository at a specific revision. The volume name is derived from a hash of the entire checkout definition (owner, repo URL, revision, key, submodules flag, etc.) so any change to any field produces a distinct volume. Keys are sorted before hashing so field order in the config file does not affect the result. Read-only from the task container's perspective. Whether it is reused or re-checked-out on each run depends on whether the revision is mutable (see below). **Worktree mutations** — a named Docker volume holding the overlayfs upper layer for a specific run. Unique per run. Contains only what the container wrote or modified — the base worktree is never touched. -From inside the container these appear as a single mount point (the overlayfs merged view). From outside, the CI system names and manages them separately. +Each repository is mounted at a distinct path inside the container. From the outside, the CI system names and manages the volumes per repository independently. Using named Docker volumes means the checkout is a straightforward `git clone` or `git checkout` into a volume — no need for the `--git-dir` / `--work-tree` / `GIT_INDEX_FILE` technique. That approach remains useful for direct host-based deployments but is out of scope here.