Files
cozystack/.github/workflows/pull-requests-release.yaml
T
Myasnikov DaniilandClaude Fable 5 d252fc93a0 ci(release): make rc promotion transactional; defer registry writes to finalize
Promotion mutated the registry at dispatch time — retagging ~37 images to
vX.Y.Z (+:latest), publishing the stable cozy-installer chart — before the
promote PR's e2e ran and before any merge. An abandoned promotion then left
stable-named bytes for a version that never shipped, and the (correct) image
write-once check wedged any later re-promotion to the same version.

Move every irreversible registry side effect into pull-requests-release.yaml
finalize, which runs only after the promote PR merged (= after its e2e passed),
making promotion atomic. promote-rc.yaml now only stages the release-X.Y.Z
branch, drafts the stable release, and opens the PR; an abandoned promotion
leaves nothing but an overwritable draft + branch.

Folded into the same restructure:

- Cut the api/apps/v1alpha1/vX.Y.Z Go-module tag in finalize, at the stable
  tag's commit. It was gated in tags.yaml on `release_exists==false &&
  is_stable`, unsatisfiable under promotion (the draft always pre-exists), so
  promoted stables never got their submodule tag — a regression for Go
  consumers. Finalize is where the stable tag is born, so it cuts the matching
  submodule tag write-once (stable only).
- Gate :latest by max-semver. promote-retag.sh now moves :latest only when
  MOVE_LATEST=1 (default off); finalize passes it the same make_latest decision
  the release gets, so a patch on an older line moves neither the release's
  `latest` nor the images' :latest. Added MOVE_LATEST bats coverage.
- Stamp platformVersion into the published stable chart's default values
  (uncommitted, at package time) so `helm --install --version X.Y.Z` reports the
  stable version in telemetry instead of the rc version baked into the operator
  image. Not committed to the branch — that would leak platformVersion=vX.Y.Z
  onto main and mis-stamp the next rc.
- promote-rc: serialize all promotions (static concurrency group, not per-rc);
  label the PR full-e2e so the full suite runs; scope the rc->stable sed to the
  depth-2 values.yaml glob plus the kubernetes .tag files (the build's own write
  set); tolerate a leftover draft so re-promotion is not wedged.
- finalize: assert the PR author is cozystack-ci[bot], and run on an ephemeral
  oracle-vm runner (needs skopeo/helm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-06 17:44:53 +05:00

413 lines
19 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: "Releasing PR"
on:
pull_request:
types: [closed]
paths-ignore:
- 'docs/**/*'
# Cancel in‑flight runs for the same PR when a new push arrives.
concurrency:
group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
# Top-level token defaults to read-only; jobs request the minimum extra scopes.
permissions:
contents: read
jobs:
finalize:
name: Finalize Release
# Runs skopeo + helm against GHCR (the promotion's registry side effects),
# so it needs an ephemeral VM runner with those tools, not the self-hosted
# pool.
runs-on: [oracle-vm-4cpu-16gb-x86-64]
permissions:
contents: write
packages: write
# Finalize now both cuts the write-once tags AND performs the promotion's
# registry side effects (retag rc->stable, move :latest, publish the stable
# chart), so restrict who can trigger it: the PR must be merged, carry the
# `release` label, AND be authored by the release bot (promote-rc.yaml opens
# it as cozystack-ci[bot]). Merge rights already gate this; asserting the
# author additionally closes the "name a branch release-X.Y.Z, get the label
# applied, merge it" hole.
if: |
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release') &&
github.event.pull_request.user.login == 'cozystack-ci[bot]'
env:
# The registry promotion writes to: component images and the cozy-installer
# chart. GHCR — the release registry tags.yaml builds rc images into — NOT
# the OCIR CI registry. Must match hack/promote-retag.sh's default.
REGISTRY: ghcr.io/cozystack/cozystack
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
# Extract tag from branch name (branch = release-X.Y.Z*)
- name: Extract tag from branch name
id: get_tag
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const branch = context.payload.pull_request.head.ref;
const m = branch.match(/^release-(\d+\.\d+\.\d+(?:[-\w\.]+)?)$/);
if (!m) {
core.setFailed(`Branch '${branch}' does not match 'release-X.Y.Z[-suffix]'`);
return;
}
const tag = `v${m[1]}`;
core.setOutput('tag', tag);
console.log(`✅ Tag to publish: ${tag}`);
# Checkout repo & create / push annotated tag
- name: Checkout repo
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
# 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}
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
# Tag the api/apps/v1alpha1 submodule for pkg.go.dev — stable releases only.
#
# Moved here from tags.yaml: under the promote flow the stable draft already
# exists when the stable tag is pushed, so tags.yaml's prepare-release body
# (which used to cut this) is skipped and the Go-module tag was never
# created for a promoted stable. Finalize is where the stable tag is born,
# so it is where the matching submodule tag must be cut, at the same commit.
# Write-once: api/apps/v1alpha1/* tags feed the Go module proxy and
# pkg.go.dev, where a moved tag poisons caches irreversibly — create when
# absent, no-op if it already points here, fail loudly if it points
# elsewhere. Skipped for a prerelease tag (an rc/beta/alpha Go-module tag
# would permanently pollute the proxy — #2677 goal 3); rc promotions never
# open a release PR so never reach finalize, but the guard is kept explicit.
- name: Tag API submodule (write-once)
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
VTAG: ${{ steps.get_tag.outputs.tag }}
SHA: ${{ github.sha }}
run: |
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}
# The submodule tag points at the same commit as the stable tag created
# above — github.sha, the merge commit. Resolve from SHA directly (not
# from the vX.Y.Z tag) so an idempotent re-run, where the stable tag
# already existed and no local tag was created, still finds the target.
EXISTING="$(git ls-remote --tags origin "refs/tags/${SUBTAG}" | awk '{print $1}')"
if [ -z "$EXISTING" ]; then
git tag "${SUBTAG}" "${SHA}"
git push origin "refs/tags/${SUBTAG}"
echo "✅ Created ${SUBTAG} at ${SHA}."
elif [ "$EXISTING" = "${SHA}" ]; then
echo "✅ ${SUBTAG} already at ${SHA}; nothing to do."
else
echo "::error::${SUBTAG} 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
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const tag = '${{ steps.get_tag.outputs.tag }}'; // e.g. v0.1.3 or v0.1.3-rc3
const match = tag.match(/^v(\d+)\.(\d+)\.\d+(?:[-\w\.]+)?$/);
if (!match) {
core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-suffix'`);
return;
}
const line = `${match[1]}.${match[2]}`;
const branch = `release-${line}`;
// Get main branch commit for the tag
const ref = await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${tag}`
});
const commitSha = ref.data.object.sha;
try {
await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch
});
// 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) {
// Only a non-fast-forward rejection (422) is the expected,
// recoverable case. Auth/API/malformed-ref errors must not be
// swallowed — they'd silently leave the maintenance branch stale.
if (ffErr.status === 422) {
console.log(`::warning::Could not fast-forward '${branch}' to ${commitSha} (not a descendant); leaving as-is — resolve manually. (${ffErr.message})`);
} else {
core.setFailed(`Unexpected error updating '${branch}': ${ffErr.message}`);
throw ffErr;
}
}
} catch (err) {
if (err.status === 404) {
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/heads/${branch}`,
sha: commitSha
});
console.log(`✅ Created branch '${branch}' at ${commitSha}`);
} else {
console.error('Unexpected error --', err);
core.setFailed(`Unexpected error creating/updating branch: ${err.message}`);
throw err;
}
}
# Publish draft release and ensure correct latest flag
- name: Publish draft release
id: publish
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const tag = '${{ steps.get_tag.outputs.tag }}';
const m = tag.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/);
if (!m) {
core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`);
return;
}
const isRc = Boolean(m[2]);
// Parse semver string to comparable numbers
function parseSemver(v) {
const match = v.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3])
};
}
// Compare two semver objects
function compareSemver(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
const currentSemver = parseSemver(tag);
// Get all releases
const releases = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
// Find draft release to publish.
//
// Normally the draft was created by tags.yaml with tag_name === tag.
// But if the git tag was deleted and re-created between draft creation
// and merge (e.g. the release branch was force-updated), GitHub orphans
// the draft: tag_name becomes "untagged-<hash>" while `name` is kept.
// Fall back to matching by `name` and repair the draft's tag_name
// before publishing — otherwise the published release would not point
// at the real git tag.
let draft = releases.data.find(r => r.draft && r.tag_name === tag);
if (!draft) {
const orphan = releases.data.find(r =>
r.draft && r.name === tag && /^untagged-/.test(r.tag_name)
);
if (!orphan) throw new Error(`Draft release for ${tag} not found`);
console.log(`🔧 Repairing orphaned draft ${orphan.id}: tag_name '${orphan.tag_name}' -> '${tag}'`);
const repaired = await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: orphan.id,
tag_name: tag
});
draft = repaired.data;
}
// Find max semver among published releases (excluding current draft)
const publishedReleases = releases.data
.filter(r => !r.draft && !r.prerelease)
.filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name))
.map(r => ({ id: r.id, tag: r.tag_name, semver: parseSemver(r.tag_name) }))
.filter(r => r.semver !== null);
let maxRelease = null;
for (const rel of publishedReleases) {
if (!maxRelease || compareSemver(rel.semver, maxRelease.semver) > 0) {
maxRelease = rel;
}
}
// Determine if this release should be latest
const isOutdated = maxRelease && compareSemver(currentSemver, maxRelease.semver) < 0;
const makeLatest = (isRc || isOutdated) ? 'false' : 'true';
// The image retag step below reuses this exact decision to gate
// whether :latest moves — the release's `latest` and the images'
// :latest must agree (a patch on an old line moves neither).
core.setOutput('make_latest', makeLatest);
if (isRc) {
console.log(`🏷️ ${tag} is a prerelease, make_latest: false`);
} else if (isOutdated) {
console.log(`🏷️ ${tag} < ${maxRelease.tag} (max semver), make_latest: false`);
} else {
console.log(`🏷️ ${tag} is the highest version, make_latest: true`);
}
// Publish the release
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: draft.id,
draft: false,
prerelease: isRc,
make_latest: makeLatest
});
console.log(`🚀 Published release ${tag}`);
// If this is a backport/outdated release, ensure the correct release is marked as latest
if (isOutdated && maxRelease) {
console.log(`🔧 Ensuring ${maxRelease.tag} remains the latest release...`);
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: maxRelease.id,
make_latest: 'true'
});
console.log(`✅ Restored ${maxRelease.tag} as latest release`);
}
# ── Transactional promotion: the registry side effects run HERE, after the
# stable tag is cut and the release is published — i.e. after the promote
# PR's e2e passed and the PR merged. promote-rc.yaml did none of this at
# dispatch time, so an abandoned promotion left no stable-named bytes.
#
# The checked-out merge commit is the promoted stable tree: the rc's
# digests with the tag string rewritten to stable. skopeo retags those
# digests to the stable image tag; helm packages+pushes the stable chart.
- name: Set up toolchain (skopeo, yq, helm)
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
run: |
command -v yq >/dev/null \
|| { sudo curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq; }
command -v skopeo >/dev/null \
|| { sudo apt-get update -qq && sudo apt-get install -y -qq skopeo; }
command -v helm >/dev/null \
|| curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Login to registry (GHCR)
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "$GHCR_TOKEN" | skopeo login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin
echo "$GHCR_TOKEN" | helm registry login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin
# Retag every rc image/digest to the stable tag. No rebuild — bit-for-bit
# the e2e-tested rc. :latest moves only when this stable is the newest
# published version, gated by the SAME make_latest decision the release got.
- name: Retag rc images to stable
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
env:
STABLE_TAG: ${{ steps.get_tag.outputs.tag }}
MOVE_LATEST: ${{ steps.publish.outputs.make_latest == 'true' && '1' || '0' }}
run: ./hack/promote-retag.sh "${STABLE_TAG}"
# Publish the stable cozy-installer OCI chart — the documented install path
# (`helm upgrade --install cozystack oci://…/cozy-installer --version X.Y.Z`).
# The retag above covers component images + the cozystack-packages artifact,
# but the chart is a separate OCI artifact referenced in no values.yaml, so
# the stable install 404s (manifest unknown) unless it is published here.
# No rebuild: the chart is packaged from the merged stable tree, which pins
# the retagged images + artifact by digest; only the chart's version and
# its platformVersion default are stamped.
- name: Publish stable cozy-installer chart
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
env:
TAG: ${{ steps.get_tag.outputs.tag }}
MAKE_LATEST: ${{ steps.publish.outputs.make_latest }}
run: |
set -eu
STABLE_VERSION="${TAG#v}"
# Bake the correct platform version into the chart's DEFAULT values so
# the primary `helm --install --version X.Y.Z` path reports the stable
# version in telemetry — the operator image itself still carries the rc
# version baked at build time, and this env override is what corrects
# it. Done on the working tree only (never committed): committing it
# would leak platformVersion=vX.Y.Z onto main/the release line when the
# promote PR merges, mis-stamping the next rc built from that branch.
export STABLE_VERSION
yq -i '.cozystackOperator.platformVersion = "v" + strenv(STABLE_VERSION)' \
packages/core/installer/values.yaml
PKG="$(helm package packages/core/installer \
--version "${STABLE_VERSION}" --app-version "${STABLE_VERSION}" | awk '{print $NF}')"
helm push "$PKG" "oci://${REGISTRY}"
rm -f "$PKG"
# cozy-installer:latest tracks the newest stable, matching the image
# :latest and the release's `latest` flag — never moved by a patch on
# an older line.
if [ "${MAKE_LATEST}" = "true" ]; then
skopeo copy --multi-arch all \
"docker://${REGISTRY}/cozy-installer:${STABLE_VERSION}" \
"docker://${REGISTRY}/cozy-installer:latest"
fi