From ae4d9db6eb14b481e7faf0aed4cf761c597e5e99 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Tue, 17 Mar 2026 12:37:30 +0200 Subject: [PATCH 1/3] fix: seed counters from existing git tags to prevent duplicates The Skyhook matrix builder used an in-memory counter that reset every run, always starting from _01. Two pushes to main on the same day would generate identical service tags, causing `gh release create` to fail with `Release.tag_name already exists`. Now queries `git ls-remote --tags origin` before building the matrix to find the highest existing counter per service for the given tag base, and starts from there. This aligns with how determine-image-tag handles counters. --- dist/index.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/index.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index 150beac..aad2d1d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32294,7 +32294,7 @@ async function run() { const serviceCounters = koalaMatrix ? getServiceCounters(koalaMatrix) : new Map(); if (configFormats.hasSkyhook) { core.info('📋 Processing Skyhook configuration (.skyhook/skyhook.yaml)'); - skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, serviceCounters); + skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, repoPath, serviceCounters); } // Determine final matrix @@ -32416,9 +32416,10 @@ async function processKoalaConfig(repoPath, branch, tag, githubToken, overlay) { * @param {string} skyhookPath - Path to skyhook.yaml * @param {string} tag - Image tag * @param {string} overlay - Environment filter + * @param {string} repoPath - Path to the git repository * @param {Map} serviceCounters - Per-service counters from Koala */ -async function processSkyhookConfig(skyhookPath, tag, overlay, serviceCounters) { +async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, serviceCounters) { const config = parseSkyhookConfig(skyhookPath); core.info(`Found ${config.services.length} services and ${config.environments.length} environments in Skyhook config`); @@ -32426,16 +32427,87 @@ async function processSkyhookConfig(skyhookPath, tag, overlay, serviceCounters) // Get service repo from environment variable const serviceRepo = process.env.GITHUB_REPOSITORY || ''; + // Query existing git tags to find highest counters per service, + // so we don't generate duplicate tags across runs on the same day. + const existingCounters = await getExistingTagCounters(config.services, tag, repoPath); + + // Merge: take the highest counter from either source + const mergedCounters = new Map(serviceCounters); + for (const [name, counter] of existingCounters) { + const current = mergedCounters.get(name) || 0; + if (counter > current) { + mergedCounters.set(name, counter); + } + } + const matrix = buildMatrixFromSkyhook(config.services, config.environments, { tag, serviceRepo, envFilter: overlay, - serviceCounters + serviceCounters: mergedCounters }); return matrix; } +/** + * Query existing git tags to find the highest counter per service for the given tag base. + * Looks for tags matching {service_name}_{tag}_NN and returns the highest NN per service. + * @param {Array} services - Array of service configurations + * @param {string} tag - Base image tag (e.g., "main_2026-03-12") + * @param {string} repoPath - Path to the git repository + * @returns {Promise>} - Map of service_name -> highest counter + */ +async function getExistingTagCounters(services, tag, repoPath) { + const counters = new Map(); + + let stdout = ''; + try { + await exec.exec('git', ['ls-remote', '--tags', 'origin'], { + cwd: repoPath, + listeners: { + stdout: (data) => { stdout += data.toString(); } + }, + silent: true + }); + } catch (err) { + core.warning(`Cannot access remote tags for counter detection: ${err.message}`); + return counters; + } + + for (const service of services) { + // Match tags like: refs/tags/{service_name}_{tag}_NN + const pattern = new RegExp(`refs/tags/${escapeRegExp(service.name)}_${escapeRegExp(tag)}_(\\d{2})$`, 'm'); + let highest = -1; + + for (const line of stdout.split('\n')) { + if (line.includes('^{}')) continue; // skip annotated tag markers + const match = line.match(pattern); + if (match) { + const counter = parseInt(match[1], 10); + if (counter > highest) { + highest = counter; + } + } + } + + if (highest >= 0) { + counters.set(service.name, highest); + core.info(`🔢 Existing tag counter for ${service.name}: ${highest}`); + } + } + + core.info(`Existing tag counters from git: ${JSON.stringify(Object.fromEntries(counters))}`); + return counters; +} + +/** + * Escape special regex characters in a string. + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + run(); module.exports = __webpack_exports__; diff --git a/src/index.js b/src/index.js index 91137f0..bb60291 100644 --- a/src/index.js +++ b/src/index.js @@ -49,7 +49,7 @@ async function run() { const serviceCounters = koalaMatrix ? getServiceCounters(koalaMatrix) : new Map(); if (configFormats.hasSkyhook) { core.info('📋 Processing Skyhook configuration (.skyhook/skyhook.yaml)'); - skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, serviceCounters); + skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, repoPath, serviceCounters); } // Determine final matrix @@ -171,9 +171,10 @@ async function processKoalaConfig(repoPath, branch, tag, githubToken, overlay) { * @param {string} skyhookPath - Path to skyhook.yaml * @param {string} tag - Image tag * @param {string} overlay - Environment filter + * @param {string} repoPath - Path to the git repository * @param {Map} serviceCounters - Per-service counters from Koala */ -async function processSkyhookConfig(skyhookPath, tag, overlay, serviceCounters) { +async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, serviceCounters) { const config = parseSkyhookConfig(skyhookPath); core.info(`Found ${config.services.length} services and ${config.environments.length} environments in Skyhook config`); @@ -181,14 +182,85 @@ async function processSkyhookConfig(skyhookPath, tag, overlay, serviceCounters) // Get service repo from environment variable const serviceRepo = process.env.GITHUB_REPOSITORY || ''; + // Query existing git tags to find highest counters per service, + // so we don't generate duplicate tags across runs on the same day. + const existingCounters = await getExistingTagCounters(config.services, tag, repoPath); + + // Merge: take the highest counter from either source + const mergedCounters = new Map(serviceCounters); + for (const [name, counter] of existingCounters) { + const current = mergedCounters.get(name) || 0; + if (counter > current) { + mergedCounters.set(name, counter); + } + } + const matrix = buildMatrixFromSkyhook(config.services, config.environments, { tag, serviceRepo, envFilter: overlay, - serviceCounters + serviceCounters: mergedCounters }); return matrix; } +/** + * Query existing git tags to find the highest counter per service for the given tag base. + * Looks for tags matching {service_name}_{tag}_NN and returns the highest NN per service. + * @param {Array} services - Array of service configurations + * @param {string} tag - Base image tag (e.g., "main_2026-03-12") + * @param {string} repoPath - Path to the git repository + * @returns {Promise>} - Map of service_name -> highest counter + */ +async function getExistingTagCounters(services, tag, repoPath) { + const counters = new Map(); + + let stdout = ''; + try { + await exec.exec('git', ['ls-remote', '--tags', 'origin'], { + cwd: repoPath, + listeners: { + stdout: (data) => { stdout += data.toString(); } + }, + silent: true + }); + } catch (err) { + core.warning(`Cannot access remote tags for counter detection: ${err.message}`); + return counters; + } + + for (const service of services) { + // Match tags like: refs/tags/{service_name}_{tag}_NN + const pattern = new RegExp(`refs/tags/${escapeRegExp(service.name)}_${escapeRegExp(tag)}_(\\d{2})$`, 'm'); + let highest = -1; + + for (const line of stdout.split('\n')) { + if (line.includes('^{}')) continue; // skip annotated tag markers + const match = line.match(pattern); + if (match) { + const counter = parseInt(match[1], 10); + if (counter > highest) { + highest = counter; + } + } + } + + if (highest >= 0) { + counters.set(service.name, highest); + core.info(`🔢 Existing tag counter for ${service.name}: ${highest}`); + } + } + + core.info(`Existing tag counters from git: ${JSON.stringify(Object.fromEntries(counters))}`); + return counters; +} + +/** + * Escape special regex characters in a string. + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + run(); From 5e441c33a63b2bda8440ad2b36edc82a02e16c6b Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Tue, 17 Mar 2026 12:40:03 +0200 Subject: [PATCH 2/3] test: add E2E test for counter respecting existing tags --- .github/workflows/test.yml | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2e7f43..5071de1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,6 +109,91 @@ jobs: echo "✅ Skyhook configuration working correctly" + test-skyhook-existing-tags: + runs-on: ubuntu-latest + name: Test Skyhook counter respects existing tags + steps: + - name: Checkout action source + uses: actions/checkout@v4 + with: + path: action-src + + - name: Setup test repo with existing tags + run: | + # Create a bare remote and working clone + git init --bare /tmp/test-remote.git + git clone /tmp/test-remote.git /tmp/test-repo + cd /tmp/test-repo + git config user.email "test@test.com" + git config user.name "Test" + git checkout -b main + + # Create skyhook config with two services + mkdir -p .skyhook apps/svc-a apps/svc-b + cat > .skyhook/skyhook.yaml << 'YAML' + services: + - name: svc-a + path: apps/svc-a + deploymentRepo: test-org/deployment + deploymentRepoPath: svc-a + - name: svc-b + path: apps/svc-b + deploymentRepo: test-org/deployment + deploymentRepoPath: svc-b + environments: + - name: dev + clusterName: dev-cluster + cloudProvider: gcp + account: test-account + location: us-east1 + namespace: dev + YAML + + git add -A && git commit -m "init" && git push -u origin main + + # Simulate previous runs: svc-a built twice, svc-b built once + git tag "svc-a_v1.0.0_01" && git tag "svc-a_v1.0.0_02" + git tag "svc-b_v1.0.0_01" + git push origin --tags + + - name: Run matrix generation + id: matrix + uses: ./action-src + with: + overlay: dev + tag: v1.0.0 + github-token: ${{ secrets.GITHUB_TOKEN }} + repo-path: /tmp/test-repo + + - name: Verify counters increment past existing tags + run: | + echo "Matrix: ${{ steps.matrix.outputs.matrix }}" + + SVC_A_TAG=$(echo '${{ steps.matrix.outputs.matrix }}' | jq -r '.include[] | select(.service_name == "svc-a") | .service_tag') + SVC_B_TAG=$(echo '${{ steps.matrix.outputs.matrix }}' | jq -r '.include[] | select(.service_name == "svc-b") | .service_tag') + + echo "svc-a tag: $SVC_A_TAG" + echo "svc-b tag: $SVC_B_TAG" + + FAIL=0 + # svc-a had _01 and _02, next should be _03 + if [[ "$SVC_A_TAG" != "svc-a_v1.0.0_03" ]]; then + echo "❌ svc-a: expected svc-a_v1.0.0_03, got $SVC_A_TAG" + ((FAIL++)) + else + echo "✅ svc-a correctly incremented to _03" + fi + + # svc-b had _01, next should be _02 + if [[ "$SVC_B_TAG" != "svc-b_v1.0.0_02" ]]; then + echo "❌ svc-b: expected svc-b_v1.0.0_02, got $SVC_B_TAG" + ((FAIL++)) + else + echo "✅ svc-b correctly incremented to _02" + fi + + [ "$FAIL" -eq 0 ] || exit 1 + test-skyhook-env-filter: runs-on: ubuntu-latest name: Test Skyhook with environment filter From 23f53214cf33856836a5f7b3cfbdb1127e5d2272 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Tue, 17 Mar 2026 13:14:42 +0200 Subject: [PATCH 3/3] fix: capture stderr from git ls-remote for better diagnostics --- dist/index.js | 6 ++++-- src/index.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dist/index.js b/dist/index.js index aad2d1d..2824ef6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32462,16 +32462,18 @@ async function getExistingTagCounters(services, tag, repoPath) { const counters = new Map(); let stdout = ''; + let stderr = ''; try { await exec.exec('git', ['ls-remote', '--tags', 'origin'], { cwd: repoPath, listeners: { - stdout: (data) => { stdout += data.toString(); } + stdout: (data) => { stdout += data.toString(); }, + stderr: (data) => { stderr += data.toString(); } }, silent: true }); } catch (err) { - core.warning(`Cannot access remote tags for counter detection: ${err.message}`); + core.warning(`Cannot access remote tags for counter detection: ${err.message}${stderr ? '\n' + stderr.trim() : ''}`); return counters; } diff --git a/src/index.js b/src/index.js index bb60291..59b588d 100644 --- a/src/index.js +++ b/src/index.js @@ -217,16 +217,18 @@ async function getExistingTagCounters(services, tag, repoPath) { const counters = new Map(); let stdout = ''; + let stderr = ''; try { await exec.exec('git', ['ls-remote', '--tags', 'origin'], { cwd: repoPath, listeners: { - stdout: (data) => { stdout += data.toString(); } + stdout: (data) => { stdout += data.toString(); }, + stderr: (data) => { stderr += data.toString(); } }, silent: true }); } catch (err) { - core.warning(`Cannot access remote tags for counter detection: ${err.message}`); + core.warning(`Cannot access remote tags for counter detection: ${err.message}${stderr ? '\n' + stderr.trim() : ''}`); return counters; }