Support multiple repo checkouts per run, one volume per repo

This commit is contained in:
2026-05-23 23:50:03 +00:00
parent 6de5060a51
commit b4f30e9479
3 changed files with 35 additions and 14 deletions

View File

@@ -14,9 +14,10 @@ checkout:
repositories: repositories:
- repo_url: git@git.efforting.tech:mikael-lovqvist/websperiments.git - repo_url: git@git.efforting.tech:mikael-lovqvist/websperiments.git
revision: main revision: main
mutable: true # true = resolve via ls-remote on each run; false = treat revision as a fixed sha mutable: true # true = re-checkout every run; false = checkout once and reuse
key: websperiments-deploy # references git-ssh/<user>/private/<key> key: websperiments-deploy # references git-ssh/<user>/private/<key>
submodules: false submodules: false
# name: custom-name # optional — overrides the default mount name (repo basename)
# Task container — runs the actual job # Task container — runs the actual job
task: task:

View File

@@ -74,6 +74,12 @@ async function compose(compose_config, args) {
}) })
} }
// Derive the mount name for a repo inside the container.
// Mimics git clone behaviour (uses repo basename) unless overridden by repo_def.name.
function repo_mount_name(repo_def) {
return repo_def.name ?? path.basename(repo_def.repo_url, '.git')
}
// Hash the entire checkout definition to get an unambiguous volume name. // Hash the entire checkout definition to get an unambiguous volume name.
// Any change to any field (repo, revision, key, submodules, etc.) produces // Any change to any field (repo, revision, key, submodules, etc.) produces
// a different volume. Human-readable metadata is stored as Docker volume labels. // a different volume. Human-readable metadata is stored as Docker volume labels.
@@ -141,7 +147,8 @@ async function prepare_worktree(owner, repo_def, private_key_path) {
// Compose config builders // Compose config builders
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function build_rsync_compose_config({ container_name, worktree_volume, deploy_target }) { // worktree_volumes: array of { volume_name, mount_name } objects
function build_rsync_compose_config({ container_name, worktree_volumes, deploy_target }) {
return { return {
name: container_name, name: container_name,
services: { services: {
@@ -153,7 +160,9 @@ function build_rsync_compose_config({ container_name, worktree_volume, deploy_ta
}, },
image: 'ci-rsync', image: 'ci-rsync',
volumes: [ volumes: [
`${worktree_volume}:/src:ro`, ...worktree_volumes.map(({ volume_name, mount_name }) =>
`${volume_name}:/src/${mount_name}:ro`
),
`${deploy_target}:/deploy`, `${deploy_target}:/deploy`,
], ],
restart: 'no', restart: 'no',
@@ -165,9 +174,9 @@ function build_rsync_compose_config({ container_name, worktree_volume, deploy_ta
}, },
}, },
}, },
volumes: { volumes: Object.fromEntries(
[worktree_volume]: { external: true }, worktree_volumes.map(({ volume_name }) => [volume_name, { external: true }])
}, ),
} }
} }
@@ -189,17 +198,28 @@ async function main() {
console.log('=== Experiment 01: rsync publish ===\n') console.log('=== Experiment 01: rsync publish ===\n')
const { secrets_base, user, checkout, task } = CONFIG const { secrets_base, user, checkout, task } = CONFIG
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, private_key_path) // Prepare one worktree volume per repository entry
console.log(`\nWorktree volume: ${worktree_volume}\n`) const worktree_volumes = await Promise.all(
checkout.repositories.map(async repo_def => {
const private_key_path = path.join(secrets_base, 'git-ssh', user, 'private', repo_def.key)
const volume_name = await prepare_worktree(user, repo_def, private_key_path)
const mount_name = repo_mount_name(repo_def)
console.log(`Worktree: ${volume_name} → /src/${mount_name}`)
return { volume_name, mount_name }
})
)
const container_name = `ci-rsync-${worktree_volume.slice('ci-worktree-'.length)}` // Container name is a hash of the full checkout set so it is stable across runs
const run_hash = createHash('sha256')
.update(JSON.stringify(checkout.repositories.map(r => worktree_volume_name(user, r)).sort()))
.digest('hex')
.slice(0, 24)
const container_name = `ci-rsync-${run_hash}`
const rsync_config = build_rsync_compose_config({ const rsync_config = build_rsync_compose_config({
container_name, container_name,
worktree_volume, worktree_volumes,
deploy_target: task.deploy_target, deploy_target: task.deploy_target,
}) })

View File

@@ -18,11 +18,11 @@ Images and tasks are declared separately. A task references an image by name. Mu
A run may check out one or more repositories. Each repository produces 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 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). **Base worktree**one named Docker volume per repository entry in the checkout definition, each containing a plain `git checkout` of that repository at its configured revision. The volume name is derived from a hash of the full checkout entry (owner, repo URL, revision, key, submodules flag, etc.) with keys sorted before hashing so field order in the config does not affect the result. Any change to any field produces a distinct volume. Base worktree volumes are read-only from the task container's perspective. Whether each 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. **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.
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. Each repository is mounted at a distinct path inside the container under `/src/<name>`. The mount name defaults to the repository basename (mimicking `git clone` behaviour) but can be overridden per entry in the task declaration. From the outside, the CI system names and manages volumes per repository entry 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. 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.