From 9a2afa83a1eb087642ea8233266dfde4ff407955 Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Tue, 23 Jun 2026 13:49:04 +0500 Subject: [PATCH] ci(release): make release tags write-once (drop force-retag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the force-retag operations in the release flow with write-once / compare-before-force guards, so published tags are never moved (the cache poisoning on api/apps/v1alpha1 via the Go module proxy, plus SBOM/scanner drift, all stem from moved tags). - tags.yaml: the api/apps/v1alpha1 submodule tag is now write-once (create if absent, no-op if unchanged, fail loudly if it would move). The release-X.Y.Z staging branch uses a compare-before-force guard (skip when unchanged; force+log only when the staging branch genuinely moves — it is a mutable staging ref, not an artifact). The build is now skipped when a release (draft OR published) already exists, so a stable tag prepared by promotion is never rebuilt here. - pull-requests-release.yaml: the stable tag is created at the PR merge commit with a write-once guard instead of `git tag -f`/`git push -f` — the merge commit did not exist before the PR opened, so force is impossible by construction. The maintenance branch is fast-forward-only (no `updateRef force:true`); a non-FF update warns and is left for a maintainer. - auto-release.yaml: removed. Stable patch tags were auto-bumped nightly via delete-recreate; stable now appears only through an explicit rc promotion. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Myasnikov Daniil --- .github/workflows/auto-release.yaml | 218 ------------------- .github/workflows/pull-requests-release.yaml | 48 +++- .github/workflows/tags.yaml | 58 ++++- 3 files changed, 84 insertions(+), 240 deletions(-) delete mode 100644 .github/workflows/auto-release.yaml diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml deleted file mode 100644 index ddea0bceb..000000000 --- a/.github/workflows/auto-release.yaml +++ /dev/null @@ -1,218 +0,0 @@ -name: Auto Patch Release - -on: - schedule: - # Run daily at 2:00 AM CET (1:00 UTC in winter, 0:00 UTC in summer) - # Using 1:00 UTC to approximate 2:00 AM CET - - cron: '0 1 * * *' - workflow_dispatch: # Allow manual trigger - -concurrency: - group: auto-release-${{ github.workflow }} - cancel-in-progress: false - -# Top-level token defaults to read-only; jobs request the minimum extra scopes. -permissions: - contents: read - -jobs: - auto-release: - name: Auto Patch Release - runs-on: [self-hosted] - permissions: - contents: write - pull-requests: read - - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 - with: - app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} - private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} - owner: cozystack - - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Configure git - env: - APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - git config user.name "cozystack-ci[bot]" - git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" - git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} - git config --unset-all http.https://github.com/.extraheader || true - - - name: Process release branches - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - env: - APP_TOKEN: ${{ steps.app-token.outputs.token }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const { execSync } = require('child_process'); - - // Configure git to use GitHub App token for authentication - execSync('git config user.name "cozystack-ci[bot]"', { encoding: 'utf8' }); - execSync('git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com"', { encoding: 'utf8' }); - execSync(`git remote set-url origin https://x-access-token:${process.env.APP_TOKEN}@github.com/${process.env.GITHUB_REPOSITORY}`, { encoding: 'utf8' }); - // Remove GITHUB_TOKEN extraheader to ensure App token is used (needed to trigger other workflows) - execSync('git config --unset-all http.https://github.com/.extraheader || true', { encoding: 'utf8' }); - - // Get all release-X.Y branches - const allBranches = execSync('git branch -r | grep -E "origin/release-[0-9]+\\.[0-9]+$" | sed "s|origin/||" | tr -d " "', { encoding: 'utf8' }) - .split('\n') - .filter(b => b.trim()) - .filter(b => /^release-\d+\.\d+$/.test(b)); - - // Only auto-patch-release the N newest minor lines. Older lines are - // EOL: we don't want stray patch tags (and broken release PRs) cut - // on them. The window slides automatically as new release-X.Y - // branches are created — once release-1.5 exists the window becomes - // {1.5, 1.4} and the trailing line retires on its own. - const SUPPORTED_LINE_COUNT = 2; - const lineNum = b => { - const [maj, min] = b.replace('release-', '').split('.').map(Number); - return maj * 1000 + min; - }; - const branches = [...allBranches] - .sort((a, b) => lineNum(b) - lineNum(a)) - .slice(0, SUPPORTED_LINE_COUNT); - - const skipped = allBranches.filter(b => !branches.includes(b)).sort((a, b) => lineNum(a) - lineNum(b)); - console.log(`Found ${allBranches.length} release branches; auto-releasing the ${SUPPORTED_LINE_COUNT} newest: ${branches.join(', ')}`); - if (skipped.length) { - console.log(`Skipping ${skipped.length} EOL release branches: ${skipped.join(', ')}`); - } - - // Get all published releases (not draft) - const allReleases = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100 - }); - - // Filter to only published releases (not draft) with tags matching vX.Y.Z (no suffixes) - const publishedReleases = allReleases.data - .filter(r => !r.draft) - .filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)); - - console.log(`Found ${publishedReleases.length} published releases without suffixes`); - - for (const branch of branches) { - console.log(`\n=== Processing branch: ${branch} ===`); - - // Extract X.Y from branch name (release-X.Y) - const match = branch.match(/^release-(\d+\.\d+)$/); - if (!match) { - console.log(` ⚠️ Branch ${branch} doesn't match pattern, skipping`); - continue; - } - - const [major, minor] = match[1].split('.'); - const versionPrefix = `v${major}.${minor}.`; - - console.log(` Looking for releases with prefix: ${versionPrefix}`); - - // Find the latest published release for this branch (vX.Y.Z without suffixes) - const branchReleases = publishedReleases - .filter(r => r.tag_name.startsWith(versionPrefix)) - .filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)); // Ensure no suffixes - - if (branchReleases.length === 0) { - console.log(` ⚠️ No published releases found for ${branch}, skipping`); - continue; - } - - // Sort by version (descending) to get the latest - branchReleases.sort((a, b) => { - const aVersion = a.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); - const bVersion = b.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); - if (!aVersion || !bVersion) return 0; - - const aNum = parseInt(aVersion[1]) * 10000 + parseInt(aVersion[2]) * 100 + parseInt(aVersion[3]); - const bNum = parseInt(bVersion[1]) * 10000 + parseInt(bVersion[2]) * 100 + parseInt(bVersion[3]); - return bNum - aNum; - }); - - const latestRelease = branchReleases[0]; - console.log(` ✅ Latest published release: ${latestRelease.tag_name}`); - - // Get the commit SHA for this release tag - let releaseCommitSha; - try { - releaseCommitSha = execSync(`git rev-list -n 1 ${latestRelease.tag_name}`, { encoding: 'utf8' }).trim(); - console.log(` Release commit SHA: ${releaseCommitSha}`); - } catch (error) { - console.log(` ⚠️ Could not find commit for tag ${latestRelease.tag_name}, skipping`); - continue; - } - - // Checkout the branch - execSync(`git fetch origin ${branch}:${branch}`, { encoding: 'utf8' }); - execSync(`git checkout ${branch}`, { encoding: 'utf8' }); - - // Get the latest commit on the branch - const latestBranchCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); - console.log(` Latest branch commit: ${latestBranchCommit}`); - - // Check if there are new commits after the release - const commitsAfterRelease = execSync( - `git rev-list ${releaseCommitSha}..HEAD --oneline`, - { encoding: 'utf8' } - ).trim(); - - if (!commitsAfterRelease) { - console.log(` ℹ️ No new commits after ${latestRelease.tag_name}, skipping`); - continue; - } - - console.log(` ✅ Found new commits after release:`); - console.log(commitsAfterRelease); - - // Calculate next version (Z+1) - const versionMatch = latestRelease.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); - if (!versionMatch) { - console.log(` ❌ Could not parse version from ${latestRelease.tag_name}, skipping`); - continue; - } - - const nextPatch = parseInt(versionMatch[3]) + 1; - const nextTag = `v${versionMatch[1]}.${versionMatch[2]}.${nextPatch}`; - - console.log(` 🏷️ Creating new tag: ${nextTag} on commit ${latestBranchCommit}`); - - // Create and push the tag with base_ref for workflow triggering - try { - // Delete local tag if exists to force update - try { - execSync(`git tag -d ${nextTag}`, { encoding: 'utf8' }); - } catch (e) { - // Tag doesn't exist locally, that's fine - } - - // Delete remote tag if exists - try { - execSync(`git push origin :refs/tags/${nextTag}`, { encoding: 'utf8' }); - } catch (e) { - // Tag doesn't exist remotely, that's fine - } - - // Create tag locally - execSync(`git tag ${nextTag} ${latestBranchCommit}`, { encoding: 'utf8' }); - - // Push tag with HEAD reference to preserve base_ref - execSync(`git push origin HEAD:refs/tags/${nextTag}`, { encoding: 'utf8' }); - console.log(` ✅ Successfully created and pushed tag ${nextTag}`); - } catch (error) { - console.log(` ❌ Error creating/pushing tag ${nextTag}: ${error.message}`); - core.setFailed(`Failed to create tag ${nextTag} for branch ${branch}`); - } - } - - console.log(`\n✅ Finished processing all release branches`); - diff --git a/.github/workflows/pull-requests-release.yaml b/.github/workflows/pull-requests-release.yaml index 101f566a1..72101c2b7 100644 --- a/.github/workflows/pull-requests-release.yaml +++ b/.github/workflows/pull-requests-release.yaml @@ -57,15 +57,33 @@ jobs: with: fetch-depth: 0 - - name: Create tag on merge commit + # Create the release tag at the merge commit — write-once. + # + # The tag is attached to the PR merge commit, which did not exist before + # this release PR opened, so there is never a prior SHA to overwrite: a + # force is impossible by construction. The guard makes that explicit and + # makes re-runs idempotent. A pre-existing tag at a different commit means + # something pushed the tag out-of-band — fail loudly rather than move it. + - name: Create tag on merge commit (write-once) env: APP_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ steps.get_tag.outputs.tag }} + SHA: ${{ github.sha }} run: | git config user.name "cozystack-ci[bot]" git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} - git tag -f ${{ steps.get_tag.outputs.tag }} ${{ github.sha }} - git push -f origin ${{ steps.get_tag.outputs.tag }} + EXISTING="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')" + if [ -z "$EXISTING" ]; then + git tag "${TAG}" "${SHA}" + git push origin "refs/tags/${TAG}" + echo "✅ Created ${TAG} at ${SHA}." + elif [ "$EXISTING" = "${SHA}" ]; then + echo "✅ ${TAG} already at ${SHA}; nothing to do." + else + echo "::error::${TAG} already exists at ${EXISTING}; refusing to force-move it to ${SHA} (tags are write-once)." + exit 1 + fi # Ensure maintenance branch release-X.Y - name: Ensure maintenance branch release-X.Y @@ -98,14 +116,22 @@ jobs: branch }); - await github.rest.git.updateRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${branch}`, - sha: commitSha, - force: true - }); - console.log(`🔁 Force-updated '${branch}' to ${commitSha}`); + // Fast-forward only — the maintenance branch is never rewound. + // force:false makes GitHub reject (422) a non-fast-forward update; + // we warn and leave the branch for a maintainer to resolve rather + // than clobber history. + try { + await github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}`, + sha: commitSha, + force: false + }); + console.log(`✅ Fast-forwarded '${branch}' to ${commitSha}`); + } catch (ffErr) { + console.log(`::warning::Could not fast-forward '${branch}' to ${commitSha} (not a descendant); leaving as-is — resolve manually. (${ffErr.message})`); + } } catch (err) { if (err.status === 404) { await github.rest.git.createRef({ diff --git a/.github/workflows/tags.yaml b/.github/workflows/tags.yaml index b7f3625d1..ac2bd9eed 100644 --- a/.github/workflows/tags.yaml +++ b/.github/workflows/tags.yaml @@ -37,7 +37,14 @@ jobs: private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} owner: cozystack - # Check if a non-draft release with this tag already exists + # Skip the build when a release (draft OR published) already exists for + # this tag: + # - re-runs of the same rc/nightly tag become idempotent no-ops; + # - a stable tag prepared by the promote flow must NOT be rebuilt here. + # promote-rc.yaml creates the draft vX.Y.Z from the already-built, + # already-tested rc artifacts; promotion ships those exact bits, never + # a fresh build. When finalize pushes the stable tag, this guard sees + # the draft and skips straight to changelog/website. - name: Check if release already exists id: check_release uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 @@ -48,9 +55,9 @@ jobs: owner: context.repo.owner, repo: context.repo.repo }); - const exists = releases.data.some(r => r.tag_name === tag && !r.draft); + const exists = releases.data.some(r => r.tag_name === tag); core.setOutput('skip', exists); - console.log(exists ? `Release ${tag} already published` : `No published release ${tag}`); + console.log(exists ? `Release ${tag} already exists (draft or published)` : `No release ${tag} yet`); # If a published release already exists, skip the rest of the workflow - name: Skip if release already exists @@ -141,20 +148,34 @@ jobs: git add . git commit -m "Prepare release ${GITHUB_REF#refs/tags/}" -s || echo "No changes to commit" - # Tag the api/apps/v1alpha1 submodule for pkg.go.dev - - name: Tag API submodule + # Tag the api/apps/v1alpha1 submodule for pkg.go.dev. + # + # Write-once: api/apps/v1alpha1/* tags feed the Go module proxy and + # pkg.go.dev, where a moved tag poisons caches irreversibly. Create the tag + # only when absent; if it already points at the same commit, no-op; if it + # exists pointing elsewhere, fail loudly rather than force-move it. + - name: Tag API submodule (write-once) if: steps.check_release.outputs.skip == 'false' env: APP_TOKEN: ${{ steps.app-token.outputs.token }} + VTAG: ${{ steps.tag.outputs.tag }} run: | - VTAG="${{ steps.tag.outputs.tag }}" SUBTAG="api/apps/v1alpha1/${VTAG}" git config user.name "cozystack-ci[bot]" git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} TARGET="$(git rev-parse "${VTAG}^{}")" - git tag -f "${SUBTAG}" "$TARGET" - git push -f origin "refs/tags/${SUBTAG}" + EXISTING="$(git ls-remote --tags origin "refs/tags/${SUBTAG}" | awk '{print $1}')" + if [ -z "$EXISTING" ]; then + git tag "${SUBTAG}" "$TARGET" + git push origin "refs/tags/${SUBTAG}" + echo "✅ Created ${SUBTAG} at ${TARGET}." + elif [ "$EXISTING" = "$TARGET" ]; then + echo "✅ ${SUBTAG} already at ${TARGET}; nothing to do." + else + echo "::error::${SUBTAG} already exists at ${EXISTING}; refusing to force-move it to ${TARGET} (tags are write-once)." + exit 1 + fi # Create or reuse draft release - name: Create / reuse draft release @@ -196,7 +217,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Create release-X.Y.Z branch and push (force-update) + # Create the release-X.Y.Z staging branch (holds the digest-bake commit for + # this version's release PR). + # + # Compare-before-force: this is a mutable staging branch, not an immutable + # artifact — a re-run produces a fresh, non-reproducible bake commit. Skip + # the push when nothing changed; force-update (and log it) only when the + # branch genuinely moves. Unlike the tags above, force here is legitimate. - name: Create release branch if: steps.check_release.outputs.skip == 'false' env: @@ -206,8 +233,17 @@ jobs: git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} BRANCH="release-${GITHUB_REF#refs/tags/v}" - git branch -f "$BRANCH" - git push -f origin "$BRANCH" + LOCAL="$(git rev-parse HEAD)" + REMOTE="$(git ls-remote --heads origin "$BRANCH" | awk '{print $1}')" + if [ -z "$REMOTE" ]; then + git push origin "HEAD:refs/heads/${BRANCH}" + echo "✅ Created staging branch ${BRANCH} at ${LOCAL}." + elif [ "$REMOTE" = "$LOCAL" ]; then + echo "✅ ${BRANCH} already at ${LOCAL}; nothing to do." + else + echo "::notice::Force-updating staging branch ${BRANCH}: ${REMOTE} -> ${LOCAL}." + git push -f origin "HEAD:refs/heads/${BRANCH}" + fi # Create pull request into original base branch (if absent) - name: Create pull request if not exists