ci(release): make release tags write-once (drop force-retag)

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) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov DaniilandClaude Opus 4.8 committed 2026-07-06 10:03:46 +05:00
1 parent fd7fcbda4e
commit 9a2afa83a1
3 files changed
+84 -240

No files matched your search

-218
View File
@@ -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`);
+37 -11
View File
@@ -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({
+47 -11
View File
@@ -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