@@ -106,71 +106,97 @@ jobs:
106106 }
107107
108108 // ---- Scan & collect tags to create ----
109- let created = 0, skippedNoMatch = 0, skippedExists = 0;
110- const toCreate = []; // { name, sha, subject }
109+ const planned = new Map(); // tagName -> { sha, subject }
110+ let created = 0, skippedNoMatch = 0, skippedAlreadyTagged = 0;
111+
112+ // Use local tags (fetched with actions/checkout fetch-tags: true) as our "remote view".
113+ function localTagExists(name) {
114+ try { sh(`git rev-parse -q --verify "refs/tags/${name}"`); return true; } catch { return false; }
115+ }
116+
117+ // Does ANY tag for this base already exist? (base, -a, -b, -aa, ...)
118+ // If yes, we skip creating more for this base.
119+ function anyLocalTagForBase(base) {
120+ const out = sh(`git tag -l "${base}*"`);
121+ return out && out.length > 0;
122+ }
123+
124+ // Suffix maker: 0 -> "", 1 -> "-a", 2 -> "-b", ..., 26 -> "-z", 27 -> "-aa", etc.
125+ function letterSuffix(n) {
126+ if (n <= 0) return "";
127+ let s = "";
128+ while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); }
129+ return "-" + s;
130+ }
131+
132+ // Find the next free name considering BOTH local tags and what's already planned in this run.
133+ function nextAvailableNameLocal(base) {
134+ let n = 0;
135+ for (;;) {
136+ const candidate = base + letterSuffix(n);
137+ if (!localTagExists(candidate) && !planned.has(candidate)) return candidate;
138+ n++;
139+ }
140+ }
111141
112142 for (const sha of shas) {
113143 const subject = sh(`git show -s --format=%s ${sha}`);
114144 const m = subject.match(pattern);
115145 if (!m) { skippedNoMatch++; continue; }
116146
117147 const groups = m.groups || {};
118- let rawTag = tagFormat.replace(/\$\{([^}]+)\}/g, (_, name) =>
119- groups[name] != null ? groups[name] : ""
120- );
148+ let rawTag = tagFormat.replace(/\$\{([^}]+)\}/g, (_, name) => groups[name] ?? "");
121149 if (!rawTag) rawTag = subject;
122150
123- const tagName = sanitizeTag(rawTag);
124-
125- // Skip if tag already exists remotely
126- let exists = false;
127- try {
128- await github.rest.git.getRef({
129- owner: context.repo.owner,
130- repo: context.repo.repo,
131- ref: `tags/${tagName}`,
132- });
133- exists = true;
134- } catch (_) {}
135-
136- if (exists) {
137- core.info(`EXISTS : ${tagName} -> ${sha} (${subject})`);
138- skippedExists++;
151+ const baseName = sanitizeTag(rawTag);
152+
153+ // RULE: if ANY tag already exists for this base (base or suffixed), skip entirely.
154+ if (anyLocalTagForBase(baseName)) {
155+ skippedAlreadyTagged++;
156+ core.info(`SKIP : Base already tagged remotely "${baseName}*" -> ${sha} (${subject})`);
139157 continue;
140158 }
141159
142160 if (dryRun) {
143- core.notice(`[DRY RUN] Would create tag "${tagName}" -> ${sha} (${subject})`);
161+ const preview = nextAvailableNameLocal(baseName);
162+ core.notice(`[DRY RUN] Would create tag "${preview}" -> ${sha} (${subject})`);
144163 continue;
145164 }
146165
147- toCreate.push({ name: tagName, sha, subject });
166+ // No existing tag for this base -> assign the first free name for this run
167+ const finalName = nextAvailableNameLocal(baseName);
168+ planned.set(finalName, { sha, subject });
148169 }
149170
150171 // ---- Create locally & push (batched) ----
151- if (!dryRun && toCreate.length) {
152- for (const t of toCreate) {
153- sh(`git tag --force "${t.name}" ${t.sha}`);
172+ if (!dryRun && planned.size) {
173+ // Create tags locally
174+ for (const [name, { sha, subject }] of planned.entries()) {
175+ sh(`git tag --force "${name}" ${sha}`);
154176 created++;
155- core.info(`Tagged locally: ${t.name} -> ${t.sha} (${t.subject})`);
156- }
157- const chunkSize = 50;
158- for (let i = 0; i < toCreate.length; i += chunkSize) {
159- const chunk = toCreate.slice(i, i + chunkSize)
160- .map(t => `refs/tags/${t.name}:refs/tags/${t.name}`);
161- sh(`git push origin ${chunk.join(' ')}`);
162- core.notice(`Pushed ${chunk.length} tags`);
177+ core.info(`Tagged locally: ${name} -> ${sha} (${subject})`);
163178 }
179+ // Push in chunks
180+ const entries = Array.from(planned.keys());
181+ const chunkSize = 50;
182+ for (let i = 0; i < entries.length; i += chunkSize) {
183+ const chunk = entries.slice(i, i + chunkSize)
184+ .map(name => `refs/tags/${name}:refs/tags/${name}`);
185+ sh(`git push origin ${chunk.join(' ')}`);
186+ core.notice(`Pushed ${chunk.length} tags`);
187+ }
164188 }
165189
166190 // ---- Summary ----
191+ const suffixedCount = Array.from(planned.keys()).filter(n => /-[a-z]+$/.test(n)).length;
167192 core.summary
168193 .addHeading("Backfill summary")
169194 .addRaw(`Ref scanned: ${ref}\n`)
170195 .addRaw(`Since : ${since || "(none)"}\n`)
171196 .addRaw(`Dry run : ${dryRun}\n`)
172197 .addRaw(`Total scanned: ${shas.length}\n`)
173198 .addRaw(`Created tags: ${created}\n`)
174- .addRaw(`Skipped (exists ): ${skippedExists }\n`)
199+ .addRaw(`Skipped (already tagged base ): ${skippedAlreadyTagged }\n`)
175200 .addRaw(`Skipped (no match): ${skippedNoMatch}\n`)
201+ .addRaw(`Suffixed (a/b/c/...): ${suffixedCount}\n`)
176202 .write();
0 commit comments