Tag a release across many repositories
One version tag applied to every repository in a release, created locally first, verified, and pushed only once every repository has tagged cleanly.
When you finish a release is one command, and a half-applied release is something the flow refuses to produce rather than something you clean up.
Why bother
Section titled “Why bother”Tagging thirty repositories by hand is thirty chances to make the release inconsistent.
| Without this | With this |
|---|---|
A typo puts v1.2.O on one repository | One variable, used everywhere |
| The tenth repository fails and ten are already pushed | Tag all locally, push only when all succeeded |
| Lightweight tags with no author, date or message | Annotated tags carrying who cut the release and when |
| A tag silently moves because it already existed | The flow checks first and stops |
| ”Which repositories are in 1.2.0?” is archaeology | The list is the answer |
The two-phase shape — tag everything locally, then push — is the whole point. Local tags are free to delete; pushed tags are not, and a partially pushed release is the mess you are avoiding.
Before you start
Section titled “Before you start”| You need | Why |
|---|---|
| The fleet on disk and current | Tag what you think you are tagging |
| Push access | The second phase |
| A decided version | Bump it deliberately, not from a script |
Step 1 — name the release once
Section titled “Step 1 — name the release once”name: tag-releaseworkingdirectory: ~/workspacevars: version: "1.2.0" message: "Release 1.2.0"tables: repos: type: csv file: ./fleet.csvStep 2 — refuse to move an existing tag
Section titled “Step 2 — refuse to move an existing tag”The most damaging failure in release tagging is a tag that silently moves. Check before creating.
tasks: - name: precheck table: repos shell: | cd {{name}} || exit 0 if git rev-parse -q --verify "refs/tags/{{version}}" >/dev/null; then echo "ERROR {{name}} already has {{version}}" exit 1 fi echo "ok {{name}}"No continueonerror on this flow: if any repository already carries the version, stop and let a
human decide.
Step 3 — tag locally, annotated
Section titled “Step 3 — tag locally, annotated” - name: tag table: repos shell: | set -e cd {{name}} git tag -a "{{version}}" -m "{{message}}" echo "tagged {{name}} $(git rev-parse --short HEAD)"-a makes it annotated: a real object with an author, a date and a message. A lightweight tag is
just a moving pointer and tells you nothing later.
Step 4 — show what you are about to publish
Section titled “Step 4 — show what you are about to publish”The last moment where undoing costs nothing.
- name: review table: repos shell: | cd {{name}} || exit 0 printf "%-28s %s\n" "{{name}}" "$(git rev-parse --short {{version}})"Step 5 — push
Section titled “Step 5 — push” - name: push table: repos shell: | set -e cd {{name}} git push origin "{{version}}" echo "pushed {{name}} {{version}}"The finished thing
Section titled “The finished thing”name: tag-releaselist: trueworkingdirectory: ~/workspacevars: version: "1.2.0" message: "Release 1.2.0"tables: repos: type: csv file: ./fleet.csv
tasks: - name: precheck table: repos shell: | cd {{name}} || exit 0 if git rev-parse -q --verify "refs/tags/{{version}}" >/dev/null; then echo "ERROR {{name}} already has {{version}}" exit 1 fi echo "ok {{name}}"
- name: tag table: repos shell: | set -e cd {{name}} git tag -a "{{version}}" -m "{{message}}" echo "tagged {{name}} $(git rev-parse --short HEAD)"
- name: review table: repos shell: | cd {{name}} || exit 0 printf "%-28s %s\n" "{{name}}" "$(git rev-parse --short {{version}})"
- name: push table: repos shell: | set -e cd {{name}} git push origin "{{version}}" echo "pushed {{name}} {{version}}"function main() { const repos = csv.read({ path: './fleet.csv' }).records;
// precheck everything before changing anything const problems = []; for (const repo of repos) { const dir = `~/workspace/${repo.name}`; const st = git.status({ directory: dir }); if (!st.clean) problems.push(`${repo.name}: working tree dirty`); const existing = shell.execute({ script: `git tag -l v${version}`, workingDir: dir, capture: true }); if (existing.output.trim()) problems.push(`${repo.name}: v${version} already exists`); } if (problems.length) throw new Error(`precheck failed:\n ${problems.join('\n ')}`);
// create locally first — a tag you have not pushed is a tag you can delete for (const repo of repos) { const dir = `~/workspace/${repo.name}`; const r = shell.execute({ script: `git tag -a v${version} -m "${message}"`, workingDir: dir, capture: true }); if (!r.success) throw new Error(`tag ${repo.name}: ${r.error}`); }
for (const repo of repos) { const dir = `~/workspace/${repo.name}`; const r = shell.execute({ script: `git push origin v${version}`, workingDir: dir, capture: true }); if (!r.success) throw new Error(`push ${repo.name}: ${r.error} — local tags exist, nothing pushed beyond this point`); }
return { tagged: repos.length, version };}kis script run tag-release.js --vars version=1.2.0 --vars message="Release 1.2.0"Three passes, not one loop doing all three. Prechecking every repository before tagging any of them is what stops a release half-applied across a fleet.
The flow’s advantage is the record. If the push fails on repository nine of thirty, the run names it — and the tags that were pushed are the eight before it, which is exactly what you need to know to clean up.
Rehearse the whole thing first:
kis flow -f tag-release.yaml --dryrunTag and review without publishing:
kis flow -f tag-release.yaml -t precheck,tag,reviewThen publish:
kis flow -f tag-release.yaml -t pushSplitting the run with -t is how the two phases stay two phases.
Verify
Section titled “Verify”See Audit tags across the fleet (planned) — it exists precisely to answer “did every repository get this version”.
Adapt it
Section titled “Adapt it”| Change | Where |
|---|---|
| Tag a known-good commit, not HEAD | See Tag at a specific commit (planned) |
| Sign the tags | git tag -s, with the signing key available on the runner |
A prefix such as v | Put it in version, not in the tasks |
Related
Section titled “Related”- Audit tags across the fleet (planned)
- Delete a tag safely (planned)