mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-08 04:54:52 +09:00
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:
1 parent
fd7fcbda4e
commit
9a2afa83a1
3 files changed
+84
-240
No files matched your search
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user