mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-08 21:14:56 +09:00
Promote RC refuses to promote a release whose target base cannot verify
the packages candidate. It checks five markers on the base branch before
its first registry write and fails closed with "Target base
'release-1.6' lacks '<path>'". This branch satisfied one of the five
(hack/lib/image-refs.sh), so the next patch release off release-1.6
could not be dispatched at all.
Add the two halves the base has to carry:
* hack/verify-promoted-packages.sh and hack/lib/promoted-packages.sh,
byte-identical to main. The verifier pulls the candidate by its
committed digest and proves it matches the merge tree apart from the
artifact's impossible self-reference, that promotion rewrote every
version-line image tag, and that the container repo@digest set is
unchanged from the rc artifact.
* the two jobs that run it. verify-release-candidate in
pull-requests.yaml checks GitHub's prospective merge tree so a
packages/ edit pushed to the promote PR shows up as a failed check,
and a Verify stable packages candidate step in the finalize job of
pull-requests-release.yaml repeats it against the real merge commit.
Position is the whole point of the finalize half: it runs before the
write-once stable tag, the draft-release publish and the retag, so a bad
candidate is refused while nothing stable-named exists yet. The former
"Set up toolchain (skopeo, yq, helm)" and "Login to registry (GHCR)"
steps moved up with it and are no longer duplicated later in the job;
the toolchain now also installs flux and pins yq by version and sha256,
because an irreversible step must not depend on whatever
releases/latest/download resolves to today.
verify-release-candidate is keyed on the promote PR's author and its
head-branch shape rather than on the `release` label main matches. This
branch's on.pull_request.types has no `labeled` event, and the label is
applied about a second after the PR opens (a separate call in
gh pr create), so the `opened` payload carries no labels and a
label-keyed guard would never fire here.
.release-tooling is gitignored, matching main, so the trusted-base
checkout can never be staged.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
535 lines
27 KiB
YAML
535 lines
27 KiB
YAML
name: "Releasing PR"
|
||
|
||
on:
|
||
pull_request:
|
||
types: [closed]
|
||
# No paths filter. A `paths-ignore: docs/**/*` used to live here as an
|
||
# optimization, but the promote PR now carries docs/changelogs/vX.Y.Z.md —
|
||
# and `Prepare stable branch` explicitly tolerates producing no tag-string
|
||
# changes ("digests already stable"). That combination can yield a docs-only
|
||
# promote PR, which the filter would drop: no finalize run, so no tag, no
|
||
# release, and no error anywhere. Filtering is left entirely to the `if:`
|
||
# gate below (merged + `release` label + release-bot author), which is what
|
||
# actually decides whether this workflow should do anything. The cost is a
|
||
# skipped job on unrelated closed PRs.
|
||
|
||
# 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
|
||
# Do NOT persist GITHUB_TOKEN as http.extraheader. Every tag/branch push
|
||
# below re-injects the app token via `git remote set-url`, but a persisted
|
||
# extraheader silently wins over the URL credential — so those pushes
|
||
# would authenticate as GITHUB_TOKEN, and a GITHUB_TOKEN push creates no
|
||
# workflow run (GitHub anti-recursion). That is exactly why v1.6.0's
|
||
# stable tag triggered no tags.yaml run (the generate-changelog and
|
||
# update-website-docs backstops never fired). With this false, the set-url
|
||
# app token authenticates, matching how promote-rc.yaml already pushes.
|
||
persist-credentials: false
|
||
|
||
# Use the trusted pre-merge base version of the verifier, while its root
|
||
# argument remains the merged packages tree checked out above. This keeps
|
||
# the final guard independent of the release PR's executable contents.
|
||
- name: Checkout release tooling from base
|
||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||
with:
|
||
ref: ${{ github.event.pull_request.base.sha }}
|
||
path: .release-tooling
|
||
sparse-checkout: hack
|
||
persist-credentials: false
|
||
|
||
# Resolve and verify the candidate before creating any write-once stable
|
||
# git or registry name. The candidate was serialized at promote time from
|
||
# the tag-rewritten tree; this guard pulls it by the committed digest and
|
||
# proves it still matches the merge commit (apart from its impossible
|
||
# self-reference) and still pins the rc-tested container digests.
|
||
#
|
||
# This step block runs HERE, ahead of the tag creation, the draft-release
|
||
# publish and the retag, rather than in its old position after the publish:
|
||
# a guard that runs after the write-once names exist cannot refuse them.
|
||
# It absorbs the former "Set up toolchain (skopeo, yq, helm)" and "Login to
|
||
# registry (GHCR)" steps, which is why neither appears later in this job
|
||
# any more — the tools and the logins are set up once, before the first
|
||
# irreversible action, and stay in scope for the retag and chart publish.
|
||
#
|
||
# flux and yq are version-pinned: they are what this job verifies the
|
||
# candidate with and what stamps the stable chart, so `latest/download`
|
||
# would make an irreversible step depend on whatever mikefarah shipped
|
||
# this morning. The version tracks
|
||
# packages/core/testing/images/e2e-sandbox/Dockerfile. yq is additionally
|
||
# pinned by content — it stamps the chart's platformVersion, and this step
|
||
# is the last one before write-once names exist. The Flux installer is not,
|
||
# because it checksums the binary it fetches and the mutable part is its
|
||
# own bootstrap script.
|
||
- name: Set up promotion toolchain (flux, skopeo, yq, helm)
|
||
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
|
||
env:
|
||
FLUX_VERSION: "2.8.6"
|
||
YQ_VERSION: "4.53.3"
|
||
YQ_SHA256: "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4"
|
||
run: |
|
||
if ! flux version --client 2>/dev/null | grep -qx "flux: v${FLUX_VERSION}"; then
|
||
install_script="$(mktemp)"
|
||
curl -fsSL https://fluxcd.io/install.sh -o "$install_script"
|
||
sudo env "FLUX_VERSION=$FLUX_VERSION" bash "$install_script"
|
||
rm -f "$install_script"
|
||
fi
|
||
if ! yq --version 2>/dev/null | grep -q "mikefarah.* version v${YQ_VERSION}\$"; then
|
||
yq_bin="$(mktemp)"
|
||
curl -fsSL -o "$yq_bin" \
|
||
"https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64"
|
||
printf '%s %s\n' "$YQ_SHA256" "$yq_bin" | sha256sum -c -
|
||
sudo install -m 0755 "$yq_bin" /usr/local/bin/yq
|
||
rm -f "$yq_bin"
|
||
fi
|
||
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
|
||
# Not installed here, only required: `flux pull artifact` reads its
|
||
# registry credentials from ~/.docker/config.json, which is why the
|
||
# login step below runs `docker login` as well as skopeo's and helm's.
|
||
# This job's runner class is not one of the docker-using build ones, so
|
||
# say what is missing here rather than letting the login step fail with
|
||
# a bare "command not found" in the middle of the release path.
|
||
command -v docker >/dev/null \
|
||
|| { echo "::error::docker is required on this runner: flux reads registry credentials from ~/.docker/config.json"; exit 1; }
|
||
|
||
- 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" | docker login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin
|
||
echo "$GHCR_TOKEN" | skopeo login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin
|
||
echo "$GHCR_TOKEN" | helm registry login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin
|
||
|
||
- name: Verify stable packages candidate
|
||
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
|
||
env:
|
||
TAG: ${{ steps.get_tag.outputs.tag }}
|
||
run: .release-tooling/hack/verify-promoted-packages.sh "${TAG#v}"
|
||
|
||
# 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`);
|
||
}
|
||
|
||
// Release body comes from the changelog that merged with THIS PR
|
||
// (promote-rc.yaml commits it onto the release-X.Y.Z branch), read
|
||
// from the merge commit already checked out here.
|
||
//
|
||
// Deliberately not left to update-releasenotes.yaml: that workflow
|
||
// triggers on pushes to main touching docs/changelogs/v*.md, so it
|
||
// would race this job on the very same push — and if it won, it
|
||
// would find no published release yet, skip, and never fire again,
|
||
// leaving the release body permanently empty. It also only watches
|
||
// main, so a patch release whose promote PR targets release-X.Y
|
||
// would never be synced at all. Reading the file here is both
|
||
// race-free and branch-agnostic; update-releasenotes.yaml remains
|
||
// for later maintainer edits and manual re-syncs.
|
||
const fs = require('fs');
|
||
const changelogPath = `docs/changelogs/${tag}.md`;
|
||
let body;
|
||
if (fs.existsSync(changelogPath)) {
|
||
const raw = fs.readFileSync(changelogPath, 'utf8');
|
||
// Last line of defence before the body becomes public and
|
||
// immutable-by-convention. promote-rc.yaml validates the changelog
|
||
// structurally, but it can also arrive by hand-commit, and an
|
||
// empty or whitespace-only file here would blank the release notes
|
||
// more thoroughly than publishing the draft's body would.
|
||
if (raw.trim().length === 0) {
|
||
console.log(`::warning::${changelogPath} is empty or whitespace-only — refusing to use it as the release body for ${tag}. Publishing with the draft's existing body instead.`);
|
||
} else {
|
||
body = raw;
|
||
console.log(`📝 Release body from ${changelogPath} (${body.length} bytes)`);
|
||
}
|
||
} else {
|
||
console.log(`::warning::${changelogPath} not found in the merge commit — publishing ${tag} with the draft's existing body. Add the changelog and re-run 'Update Release Notes' to backfill it.`);
|
||
}
|
||
|
||
// 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,
|
||
...(body ? { body } : {})
|
||
});
|
||
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.
|
||
#
|
||
# The toolchain and the GHCR logins these two steps need are established
|
||
# earlier in this job, by "Set up promotion toolchain (flux, skopeo, yq,
|
||
# helm)" and "Login to registry (GHCR)" — both moved ahead of the tag
|
||
# creation so the candidate verification can refuse a release before any
|
||
# write-once name exists. Same job, so both are still in scope here.
|
||
|
||
# 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
|