Skip to content

Commit 8773329

Browse files
INS-11420 | Make deploy-tag recording collision-safe and idempotent
The deploy tag was numbered from a `git fetch --tags` snapshot at the START of a promote, then pushed at the END — after a minutes-long deploy — so any deploy tag that appeared meanwhile made the final push fail ("reference already exists"), turning a successful production deploy red. Re-running then minted a second numbered tag for the same commit. - Number + create + push the tag AFTER the deploy, so the number reflects origin state seconds before the push (not minutes). - Idempotent push: a losing-race collision is accepted only when it is a "reference already exists" collision (decided from the push OUTPUT, not the executor's generic exception) AND the deployed artifact matches what is already recorded. Promote verifies binary identity via Heroku slug checksum (heroku.rb same_slug?/slug_checksum); the deploy path falls back to the git commit the existing tag points at. A same-name / different-slug collision fails loudly. Adds spec/stairstep/common/git_spec.rb (the repo had no specs before): fresh tag, numbering, deploy ref-name path, failed-deploy -> no tag, deploy-before-tag ordering, fetch re-run, slug-verify both branches, non-collision re-raise, and ls-remote empty/multi-line. Tool: Claude Code (claude-opus-4-8[1m])
1 parent cd7404c commit 8773329

4 files changed

Lines changed: 278 additions & 9 deletions

File tree

lib/stairstep/common/git.rb

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "date"
2+
require "stringio"
23
require_relative "../../stairstep"
34

45
module Stairstep::Common
@@ -16,13 +17,15 @@ def commit_sha(ref_name)
1617
git("rev-parse", "--verify", ref_name, capture_stdout: true).chomp
1718
end
1819

19-
def with_tag(to_remote, commit:, message:, tag:)
20-
tag_name = build_tag_name(to_remote) if tag
21-
git("tag", "-a", "-m", message, tag_name, commit) if tag
20+
# Records the deploy as an annotated tag on origin, AFTER the deploy runs, so
21+
# a tag that appears on origin during the — potentially minutes-long — deploy
22+
# can't make us commit to a stale number and then collide on push. If the push
23+
# still loses a race, the collision is only accepted when the deployed
24+
# artifact matches what is already recorded (see #push_deploy_tag); promote
25+
# passes verify_deploy for a Heroku slug-checksum comparison.
26+
def with_tag(to_remote, commit:, message:, tag:, verify_deploy: nil)
2227
yield
23-
save_tag(tag_name) if tag
24-
ensure
25-
delete_tag(tag_name) if tag
28+
save_deploy_tag(to_remote, commit, message, verify_deploy) if tag
2629
end
2730

2831
def verify_clean_working_directory
@@ -85,8 +88,64 @@ def existing_tags
8588
end
8689
end
8790

88-
def save_tag(tag_name)
89-
git("push", "origin", tag_name)
91+
# Number, create, and push the deploy tag — all after the deploy — so the
92+
# number reflects origin state seconds before the push (not minutes) and a tag
93+
# that appeared mid-deploy can't force a stale collision. Each deploy records
94+
# its own tag; #push_deploy_tag decides whether a losing-race collision is
95+
# benign.
96+
def save_deploy_tag(remote, commit, message, verify_deploy)
97+
refresh_existing_tags!
98+
tag_name = build_tag_name(remote)
99+
git("tag", "-a", "-m", message, tag_name, commit)
100+
push_deploy_tag(tag_name, commit_sha(commit), verify_deploy)
101+
ensure
102+
delete_tag(tag_name) if tag_name
103+
end
104+
105+
# A rejected push is treated as success only when BOTH hold:
106+
# * it is a "tag already exists" collision — decided from the push output,
107+
# not the (generic) exception, and NOT an auth/network/hook failure; and
108+
# * the deployed artifact matches what the colliding tag records
109+
# (see #deployed_artifact_matches?).
110+
# Anything else re-raises.
111+
def push_deploy_tag(tag_name, deployed_sha, verify_deploy)
112+
output = StringIO.new
113+
succeeded = executor.execute("git", "push", "origin", tag_name, output: output)
114+
print(output.string)
115+
return if succeeded
116+
117+
unless tag_collision?(output.string) && deployed_artifact_matches?(tag_name, deployed_sha, verify_deploy)
118+
raise("Command failed: `git push origin #{tag_name}`")
119+
end
120+
121+
logger.info("Tag #{tag_name} already on origin and the deployed artifact matches; treating as success")
122+
end
123+
124+
def tag_collision?(push_output)
125+
push_output.match?(/reference already exists|cannot lock ref/)
126+
end
127+
128+
# Whether the just-deployed artifact matches what the colliding tag already
129+
# records. Promote supplies verify_deploy — a Heroku slug-checksum comparison
130+
# (deployed vs. live) — so a same-name / different-slug deploy fails loudly
131+
# (INS-11420 AC1). Without it, the deploy path (no from/to slug pair) falls
132+
# back to the git commit the existing tag points at.
133+
def deployed_artifact_matches?(tag_name, deployed_sha, verify_deploy)
134+
return verify_deploy.call if verify_deploy
135+
136+
remote_tag_commit(tag_name) == deployed_sha
137+
end
138+
139+
def remote_tag_commit(tag_name)
140+
# ls-remote prints "<sha>\t<ref>" per matching ref; take the sha off the ref
141+
# line (line-anchored, so a leading warning/redirect line doesn't derail it).
142+
git("ls-remote", "origin", "refs/tags/#{tag_name}^{}", capture_stdout: true)[/^(\h{7,})\s/, 1]
143+
rescue
144+
nil
145+
end
146+
147+
def refresh_existing_tags!
148+
@existing_tags = nil
90149
end
91150

92151
def delete_tag(tag_name)

lib/stairstep/common/heroku.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ def slug_id(remote)
4646
JSON.parse(release_json).dig("slug", "id") || fail
4747
end
4848

49+
# True when the two remotes are running the same slug binary, compared by the
50+
# slug's content checksum (not its git commit — one commit can back several
51+
# rebuilt slugs). Used to decide whether a deploy-tag collision is benign.
52+
def same_slug?(pipeline, from_remote, to_remote)
53+
slug_checksum(pipeline, from_remote) == slug_checksum(pipeline, to_remote)
54+
end
55+
56+
def slug_checksum(pipeline, remote)
57+
path = "/apps/#{app_name(pipeline, remote)}/slugs/#{slug_id(remote)}"
58+
JSON.parse(heroku_api("GET", path)).fetch("checksum")
59+
end
60+
4961
def scale_dynos(remote, initial_deploy:)
5062
heroku(remote, "ps:scale", *worker_dyno_counts(remote).collect { |type, _| "#{type}=0" }) unless initial_deploy
5163
yield

lib/stairstep/promote.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,15 @@ def from_commit
4848
end
4949

5050
def promote_slug
51-
git.with_tag(to_remote, commit: from_commit, message: "Deploy to #{to_remote} from #{from_remote} at #{Time.now}", tag: tag?) do
51+
git.with_tag(
52+
to_remote,
53+
commit: from_commit,
54+
message: "Deploy to #{to_remote} from #{from_remote} at #{Time.now}",
55+
tag: tag?,
56+
# Only consulted if the tag push loses a race: is prod running the slug we
57+
# just promoted? A same-name / different-slug collision then fails loudly.
58+
verify_deploy: -> { heroku.same_slug?(pipeline, from_remote, to_remote) }
59+
) do
5260
heroku.manage_deploy(to_remote, downtime: downtime?, initial_deploy: initial_deploy?) do
5361
heroku.promote_slug(pipeline, from_remote, to_remote)
5462
end

spec/stairstep/common/git_spec.rb

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
require "date"
2+
require "stairstep/common/git"
3+
4+
# Fake CommandExecutor modelling the real contract Git relies on:
5+
# * #execute(*cmd, output:) runs a command, writes its (fake) output to the
6+
# given IO, and returns a success boolean — used only for the tag push.
7+
# * #execute!(*cmd) runs a command and raises on failure — used for the other
8+
# mutating git commands.
9+
# * #fetch_stdout(:execute!, *cmd) returns canned stdout for a read command.
10+
# Every command is recorded so examples can assert what git ran.
11+
class FakeGitExecutor
12+
attr_reader :calls
13+
14+
def initialize(stdout: {}, fail_on: [], push_fails: false, push_output: "")
15+
@stdout = stdout
16+
@fail_on = fail_on
17+
@push_fails = push_fails
18+
@push_output = push_output
19+
@calls = []
20+
end
21+
22+
def execute(*command, output: nil, **)
23+
@calls << command
24+
output&.write(@push_output)
25+
!@push_fails
26+
end
27+
28+
def execute!(*command, **)
29+
@calls << command
30+
raise "Command failed: `#{command.join(" ")}`" if @fail_on.include?(command)
31+
32+
true
33+
end
34+
35+
def fetch_stdout(_exec, *command, **)
36+
@calls << command
37+
@stdout.fetch(command, "")
38+
end
39+
40+
def ran?(*command) = @calls.include?(command)
41+
42+
def pushed_any_tag? = @calls.any? { |c| c[0, 2] == ["git", "push"] }
43+
44+
def tagged_any? = @calls.any? { |c| c[0, 2] == ["git", "tag"] }
45+
end
46+
47+
RSpec.describe Stairstep::Common::Git do
48+
subject(:git) { described_class.new(executor, logger) }
49+
50+
let(:logger) { double("Logger", info: nil) }
51+
let(:executor) do
52+
FakeGitExecutor.new(stdout: stdout, fail_on: fail_on, push_fails: push_fails, push_output: push_output)
53+
end
54+
let(:fail_on) { [] }
55+
let(:push_fails) { false }
56+
let(:push_output) { "" }
57+
58+
let(:base) { "deploy-production-#{Date.today}" }
59+
let(:commit) { "abc123abc123abc123abc123abc123abc123abcd" }
60+
61+
# By default `commit` resolves to itself and today has no deploy tags yet.
62+
let(:stdout) do
63+
{
64+
["git", "rev-parse", "--verify", commit] => commit,
65+
["git", "tag"] => existing_tag_list.join("\n")
66+
}
67+
end
68+
let(:existing_tag_list) { [] }
69+
70+
def record_tag(ref: commit, verify_deploy: nil, &block)
71+
block ||= -> { :deployed }
72+
git.with_tag("production", commit: ref, message: "Deploy", tag: true, verify_deploy: verify_deploy, &block)
73+
end
74+
75+
describe "ordering / gating" do
76+
it "runs the deploy (yield) before any tagging or push" do
77+
calls_at_yield = nil
78+
record_tag { calls_at_yield = executor.calls.dup }
79+
80+
# nothing git-tag/push related had run by the time the deploy block executed
81+
expect(calls_at_yield.any? { |c| c[0, 2] == ["git", "tag"] || c[0, 2] == ["git", "push"] }).to be(false)
82+
# ...and tagging did happen afterwards
83+
expect(executor.tagged_any?).to be(true)
84+
end
85+
86+
it "does nothing when tagging is disabled" do
87+
git.with_tag("production", commit: commit, message: "Deploy", tag: false) { :deployed }
88+
89+
expect(executor.calls).to be_empty
90+
end
91+
92+
it "records no tag when the deploy fails" do
93+
expect { record_tag { raise "boom" } }.to raise_error(/boom/)
94+
95+
expect(executor.tagged_any?).to be(false)
96+
expect(executor.pushed_any_tag?).to be(false)
97+
end
98+
end
99+
100+
describe "recording a new deploy tag" do
101+
it "re-fetches tags, creates a numbered tag, pushes it, and cleans up locally" do
102+
record_tag
103+
104+
expect(executor.ran?("git", "fetch", "--tags")).to be(true) # AC2: numbering off a fresh fetch
105+
expect(executor.ran?("git", "tag", "-a", "-m", "Deploy", base, commit)).to be(true)
106+
expect(executor.ran?("git", "push", "origin", base)).to be(true)
107+
expect(executor.ran?("git", "tag", "-d", base)).to be(true)
108+
end
109+
110+
it "picks the next free number when earlier tags for today already exist" do
111+
allow_existing(base, "#{base}.1")
112+
113+
record_tag
114+
115+
expect(executor.ran?("git", "tag", "-a", "-m", "Deploy", "#{base}.2", commit)).to be(true)
116+
end
117+
118+
it "tags the resolved commit on the deploy ref-name path" do
119+
ref = "refs/deploys/production-#{Date.today}"
120+
stdout[["git", "rev-parse", "--verify", ref]] = commit # ref peels to a real SHA
121+
122+
record_tag(ref: ref)
123+
124+
expect(executor.ran?("git", "tag", "-a", "-m", "Deploy", base, ref)).to be(true)
125+
end
126+
end
127+
128+
describe "push collision — promote path (slug identity)" do
129+
let(:push_fails) { true }
130+
let(:push_output) { " ! [remote rejected] #{base} -> #{base} (cannot lock ref 'refs/tags/#{base}': reference already exists)" }
131+
132+
it "treats the collision as success when the deployed slug matches (verify_deploy true)" do
133+
expect { record_tag(verify_deploy: -> { true }) }.not_to raise_error
134+
expect(executor.ran?("git", "tag", "-d", base)).to be(true) # still cleans up local
135+
end
136+
137+
it "fails loudly when the deployed slug differs (verify_deploy false)" do
138+
expect { record_tag(verify_deploy: -> { false }) }.to raise_error(/Command failed/)
139+
end
140+
end
141+
142+
describe "push collision — deploy path (commit identity fallback)" do
143+
let(:push_fails) { true }
144+
let(:push_output) { "cannot lock ref 'refs/tags/#{base}': reference already exists" }
145+
146+
it "treats a same-commit collision as success" do
147+
stub_remote_peeled(base, commit)
148+
149+
expect { record_tag }.not_to raise_error
150+
end
151+
152+
it "re-raises when the existing tag points at a different commit" do
153+
stub_remote_peeled(base, "def456def456def456def456def456def456abcd")
154+
155+
expect { record_tag }.to raise_error(/Command failed/)
156+
end
157+
158+
it "re-raises when the tag is absent on origin (ls-remote empty)" do
159+
# no stub_remote_peeled → ls-remote returns "" → nil
160+
expect { record_tag }.to raise_error(/Command failed/)
161+
end
162+
163+
it "tolerates a multi-line ls-remote (leading warning) when extracting the sha" do
164+
stdout[["git", "ls-remote", "origin", "refs/tags/#{base}^{}"]] =
165+
"warning: redirecting to https://...\n#{commit}\trefs/tags/#{base}^{}\n"
166+
167+
expect { record_tag }.not_to raise_error
168+
end
169+
end
170+
171+
describe "non-collision push failure (auth / network)" do
172+
let(:push_fails) { true }
173+
let(:push_output) { "fatal: unable to access 'https://...': Could not resolve host: github.com" }
174+
175+
it "re-raises even if the deployed artifact would have matched" do
176+
# verify would say "matches", but the failure is NOT a tag collision
177+
expect { record_tag(verify_deploy: -> { true }) }.to raise_error(/Command failed/)
178+
end
179+
end
180+
181+
# --- helpers ---
182+
183+
def allow_existing(*names)
184+
stdout[["git", "tag"]] = names.join("\n")
185+
end
186+
187+
def stub_remote_peeled(tag_name, sha)
188+
stdout[["git", "ls-remote", "origin", "refs/tags/#{tag_name}^{}"]] = "#{sha}\trefs/tags/#{tag_name}^{}"
189+
end
190+
end

0 commit comments

Comments
 (0)