Files
cozystack/.github/workflows/pull-requests-release.yaml
Aleksei Sviridkin 8aa30472b6 docs(ci): correct the job-level permissions comment
The comment above the top-level `permissions` block claimed that jobs
request the minimum extra scopes on top of it. A job-level block does
not add to the workflow-level one: it replaces it, and every scope the
job does not name is set to none. A job needing one extra scope has to
restate `contents: read` alongside it or lose read access to the
repository.

The corrected wording states the replace semantics, so the next job
block written against this comment grants the whole set it needs
rather than only the delta.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 00:34:53 +03:00

467 lines
23 KiB
YAML
Raw Permalink 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]
# 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. A job-level block replaces this one
# rather than adding to it, so each job restates every scope it needs.
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
# 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, and it
// only watches main. Since the rc freeze every promote PR targets
// release-X.Y, so no such push exists at promote time and it would
// sync no release at all — that used to be the patch-release corner
// case and is now every release. Back when the promote PR did merge
// into main it was worse than useless: it raced this job on that very
// push, and if it won it found no published release yet, skipped, and
// never fired again, leaving the release body permanently empty.
// 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.
- 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