mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 18:47:04 +09:00
build-release.yaml publishes `cozystack-packages:<line>` on push to a release line, and pull-requests.yaml's overlay reads it so a release-line PR tests its own line's binaries instead of main's (#3471, #3437). It cannot fire for the push that creates the line. The freeze points release-X.Y at a commit that is already on main, so the push carries no new commits, and GitHub does not run a workflow whose paths/paths-ignore filter finds no changed files ("If there are no files changed, the workflow will not run"). The line would therefore have no artifact until its first cherry-pick merged, and in that window the overlay finds nothing to pull and leaves every package on its committed ref — at freeze time the previous release's, which is exactly the cross-generation mix #3437 fixed. The old flow had no such window: it branched at the promote merge commit, which carried real commits and its own release's refs. Add workflow_dispatch to build-release.yaml and have the freeze step dispatch it for the branch it just created, so the artifact exists from the moment the line does. The dispatch is non-fatal: without it the overlay no-ops and early cherry-pick PRs test their committed refs, which is where they were before #3471. The tag is pushed and the line is frozen by that point, so failing there would misreport both. The trigger also gives a line build a re-run button, which previously needed an empty commit pushed to the line. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
358 lines
18 KiB
YAML
358 lines
18 KiB
YAML
name: Cut Pre-release Tag
|
|
|
|
# Cut a pre-release tag (vX.Y.Z-alpha.N / -beta.N / -rc.N) as the CozyStack CI
|
|
# app, so that no human ever pushes a v* tag by hand.
|
|
#
|
|
# This is the single entry point for creating a pre-release. Stable vX.Y.Z tags
|
|
# are never cut here (or by hand) — they are created write-once by
|
|
# pull-requests-release.yaml finalize at a promote PR's merge commit. See
|
|
# docs/release.md and #2677.
|
|
#
|
|
# Dispatch from the branch the tag belongs to: `main` for a new minor's
|
|
# pre-release (vX.Y.0-rc.N), or `release-X.Y` for a patch line (vX.Y.Z-rc.N).
|
|
# The workflow tags that branch's HEAD.
|
|
#
|
|
# Cutting the first vX.Y.0-rc.N ALSO freezes the line: it creates `release-X.Y`
|
|
# at the tagged commit, and every later cut for that line must come from the
|
|
# branch (a dispatch from `main` is refused). That is what stops features
|
|
# merged after the rc from silently shipping in the release — before this, rc.2
|
|
# was cut from main's tip and picked up everything that had landed meanwhile.
|
|
# `main` stays open for the next minor; fixes reach the release by cherry-pick.
|
|
#
|
|
# Why a workflow instead of a manual `git push`:
|
|
# 1. The tag is pushed with the CI app token. A tag pushed with the default
|
|
# GITHUB_TOKEN would NOT trigger tags.yaml (GitHub suppresses workflow
|
|
# recursion from GITHUB_TOKEN); the app token is a distinct identity, so
|
|
# tags.yaml fires exactly as it does for a maintainer's manual push.
|
|
# 2. It pushes `HEAD:refs/tags/<tag>` from the checked-out branch tip, so the
|
|
# push event's base_ref is populated — tags.yaml's "Get base branch" step
|
|
# needs it to detect main vs release-X.Y.
|
|
# 3. It makes CI the sole creator of pre-release tags. Repo rulesets can
|
|
# already restrict *stable* tag creation to CI (a "Restrict creations"
|
|
# ruleset targeting v* while excluding v*-*), but pre-releases were
|
|
# hand-pushed, so they could not be CI-locked. With this workflow as their
|
|
# only entry point, admins can extend "Restrict creations" (CI app the sole
|
|
# bypass) to *all* of v* — pre-release and stable alike.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Pre-release tag to cut, e.g. v1.6.0-rc.1 (must be -alpha.N / -beta.N / -rc.N)"
|
|
required: true
|
|
type: string
|
|
|
|
concurrency:
|
|
# One global lane: two dispatches racing to push overlapping tags would
|
|
# confuse the write-once guard. Pre-release cuts are rare, so serialize.
|
|
group: cut-prerelease
|
|
cancel-in-progress: false
|
|
|
|
# The tag write happens via the app token, not GITHUB_TOKEN, so the job needs
|
|
# only read scope on the default token.
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
cut:
|
|
name: Cut ${{ inputs.tag }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
|
|
steps:
|
|
# Pure-input validation first — no token minted, no checkout, so a bad tag
|
|
# or wrong dispatch branch fails in seconds.
|
|
|
|
# Validate the dispatch input against a strict pre-release pattern. A
|
|
# stable vX.Y.Z is rejected on purpose: stable tags are cut only by the
|
|
# promote flow, at a PR merge commit.
|
|
- name: Parse and validate tag
|
|
id: parse
|
|
env:
|
|
TAG: ${{ inputs.tag }}
|
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
with:
|
|
script: |
|
|
const tag = process.env.TAG.trim();
|
|
const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.(\d+)$/);
|
|
if (!m) {
|
|
core.setFailed(
|
|
`tag '${tag}' must match 'vX.Y.Z-(alpha|beta|rc).N'. ` +
|
|
`Stable vX.Y.Z tags are cut by the promote flow, not here.`
|
|
);
|
|
return;
|
|
}
|
|
core.setOutput('tag', tag);
|
|
core.setOutput('line', `${m[1]}.${m[2]}`); // 1.6
|
|
core.setOutput('patch', m[3]); // 0
|
|
core.setOutput('kind', m[4]); // rc
|
|
|
|
# Refuse to cut from anything but main or the *matching* release-X.Y line.
|
|
# The pushed tag's base_ref (= the dispatch branch) must be one tags.yaml
|
|
# accepts, and the branch must actually contain the version being cut.
|
|
- name: Validate dispatch branch
|
|
env:
|
|
REF_NAME: ${{ github.ref_name }}
|
|
REF_TYPE: ${{ github.ref_type }}
|
|
LINE: ${{ steps.parse.outputs.line }}
|
|
PATCH: ${{ steps.parse.outputs.patch }}
|
|
run: |
|
|
if [ "$REF_TYPE" != "branch" ]; then
|
|
echo "::error::Dispatch this workflow from a branch (main or release-X.Y), not a tag or SHA."
|
|
exit 1
|
|
fi
|
|
case "$REF_NAME" in
|
|
main)
|
|
# From main only a *new minor's* pre-release (vX.Y.0-*) is valid.
|
|
# A patch-line pre-release (Z>0) built from main content would land
|
|
# on a wrong-tree release-X.Y.Z-rc.N staging branch — it must be cut
|
|
# from its own release-X.Y line instead.
|
|
if [ "$PATCH" != "0" ]; then
|
|
echo "::error::From 'main' only vX.Y.0-* pre-releases may be cut (got patch .$PATCH). Cut v$LINE.$PATCH-* from 'release-$LINE'."
|
|
exit 1
|
|
fi
|
|
;;
|
|
release-*)
|
|
if [ "release-$LINE" != "$REF_NAME" ]; then
|
|
echo "::error::Tag line $LINE does not match dispatch branch '$REF_NAME'. Cut v$LINE.* from 'main' or 'release-$LINE'."
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "::error::Dispatch from 'main' or 'release-X.Y' only. Got '$REF_NAME'."
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
- 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
|
|
# Scope the token to this repo only — it never touches other repos.
|
|
repositories: ${{ github.event.repository.name }}
|
|
|
|
# Cutting a release is privileged. workflow_dispatch already requires write
|
|
# access to trigger, so this is defense-in-depth: it makes the requirement
|
|
# explicit and auditable, and fails closed if that platform default ever
|
|
# changes or the job is reached by another path. Reads permission via the
|
|
# app token (fails the step if the API errors — fail closed).
|
|
- name: Require write access
|
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
with:
|
|
github-token: ${{ steps.app-token.outputs.token }}
|
|
script: |
|
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
username: context.actor,
|
|
});
|
|
// getCollaboratorPermissionLevel's `permission` field returns only
|
|
// the legacy roles admin/write/read/none (maintain collapses to
|
|
// write, triage to read), so admin+write is the whole "can push" set.
|
|
const allowed = ['admin', 'write'];
|
|
if (!allowed.includes(data.permission)) {
|
|
core.setFailed(
|
|
`@${context.actor} has '${data.permission}' access — cutting a pre-release tag ` +
|
|
`requires write access to ${context.repo.owner}/${context.repo.repo}.`
|
|
);
|
|
return;
|
|
}
|
|
core.info(`@${context.actor}: '${data.permission}' access — authorized.`);
|
|
|
|
# Minimal checkout — just enough to configure origin with the app-token
|
|
# credential (persist-credentials, default on) so the bare push below is
|
|
# app-authenticated, and to resolve HEAD. One ref, shallow, no tags.
|
|
- name: Check out the dispatched branch
|
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 1
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
|
|
# Enforce the freeze. Once release-X.Y exists, that line's content is
|
|
# closed and lives only on the branch. A vX.Y.* pre-release dispatched
|
|
# from 'main' after the freeze would tag main's tip, picking up every
|
|
# feature merged since — exactly what the release branch exists to
|
|
# prevent. Applies to alpha/beta too: after the freeze the whole line
|
|
# comes off the branch, whatever the pre-release kind.
|
|
#
|
|
# Runs BEFORE the tag push so a mistaken dispatch fails without burning
|
|
# the write-once tag name.
|
|
- name: Refuse to cut a frozen line from main
|
|
if: github.ref_name == 'main'
|
|
env:
|
|
LINE: ${{ steps.parse.outputs.line }}
|
|
run: |
|
|
# Distinguish "absent" (--exit-code exits 2) from a transport/auth
|
|
# failure, mirroring the write-once query below: an infra blip must
|
|
# not read as "not frozen, go ahead".
|
|
set +e
|
|
git ls-remote --exit-code --heads origin "refs/heads/release-$LINE" >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -eq 0 ]; then
|
|
echo "::error::release-$LINE already exists — the $LINE line is frozen. Cut v$LINE.* from 'release-$LINE' (cherry-pick fixes there first); 'main' is open for the next minor."
|
|
exit 1
|
|
elif [ "$rc" -ne 2 ]; then
|
|
echo "::error::Could not query origin for release-$LINE (git ls-remote exit $rc) — refusing to proceed."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Cut and push the pre-release tag
|
|
env:
|
|
TAG: ${{ steps.parse.outputs.tag }}
|
|
REF_NAME: ${{ github.ref_name }}
|
|
run: |
|
|
# Write-once: the tag must not already exist on origin. Distinguish
|
|
# "absent" (git ls-remote --exit-code exits 2) from a transport/auth
|
|
# failure (any other non-zero) so an infra blip cannot masquerade as
|
|
# "free to create".
|
|
set +e
|
|
git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -eq 0 ]; then
|
|
echo "::error::Tag $TAG already exists on origin — pre-release tags are write-once."
|
|
exit 1
|
|
elif [ "$rc" -ne 2 ]; then
|
|
echo "::error::Could not query origin for $TAG (git ls-remote exit $rc) — refusing to proceed."
|
|
exit 1
|
|
fi
|
|
|
|
# Stale-tip guard: workflow_dispatch checks out the dispatch-time commit,
|
|
# not the branch tip at push time (the gap includes any wait in the
|
|
# cut-prerelease concurrency queue). If the branch advanced meanwhile,
|
|
# pushing HEAD would tag a stale commit and yield an empty base_ref —
|
|
# tags.yaml would then fail, having already burned this write-once name.
|
|
# Refuse and ask for a re-run instead.
|
|
local_head="$(git rev-parse HEAD)"
|
|
# Resolve the remote tip without a pipe masking ls-remote's exit code
|
|
# (mirrors the write-once query above): a transport/auth failure must
|
|
# not read as "branch not found".
|
|
set +e
|
|
remote_ls="$(git ls-remote origin "refs/heads/$REF_NAME")"
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::error::Could not query origin for the tip of '$REF_NAME' (git ls-remote exit $rc) — refusing to proceed."
|
|
exit 1
|
|
fi
|
|
remote_head="$(printf '%s' "$remote_ls" | cut -f1)"
|
|
if [ -z "$remote_head" ]; then
|
|
echo "::error::Could not resolve the tip of '$REF_NAME' on origin (no such branch?)."
|
|
exit 1
|
|
fi
|
|
if [ "$local_head" != "$remote_head" ]; then
|
|
echo "::error::'$REF_NAME' advanced since dispatch ($local_head -> $remote_head). Re-run the workflow to tag the current tip."
|
|
exit 1
|
|
fi
|
|
|
|
# Push HEAD straight to a tag ref: no local tag object, non-forced. The
|
|
# branch-tip anchoring makes GitHub populate the push event's base_ref
|
|
# (tags.yaml's Get-base-branch step requires it); non-forced makes the
|
|
# tag write-once at the git layer. tags.yaml takes it from here: every
|
|
# pre-release is built, drafted, and given a staging branch; rc tags
|
|
# then run mandatory full E2E. Alpha/beta tags use e2e-tag.yaml's
|
|
# manual dispatch when validation is wanted.
|
|
git push origin "HEAD:refs/tags/$TAG"
|
|
echo "Pushed $TAG at $local_head from '$REF_NAME'."
|
|
echo "tags.yaml will now build and draft the pre-release and create its staging branch; rc tags also run mandatory full E2E."
|
|
|
|
# Freeze the line. An rc is the feature-freeze signal, so the first
|
|
# vX.Y.0-rc.N branches release-X.Y at the tagged commit. From here the
|
|
# release's content is closed: fixes reach it only by cherry-pick or
|
|
# backport onto the branch, while 'main' moves on to the next minor.
|
|
# promote-rc.yaml then targets its promote PR at release-X.Y (it already
|
|
# prefers that branch when it exists), so the stable release is cut from
|
|
# the frozen tree rather than from whatever main has accumulated.
|
|
#
|
|
# Gated to -rc: alpha/beta are pre-freeze validation builds cut from an
|
|
# open main, and freezing on them would force every later change onto
|
|
# cherry-picks far too early.
|
|
#
|
|
# Gated to patch 0: a patch-line rc (vX.Y.Z-rc.N, Z>0) is cut from
|
|
# release-X.Y, which by definition already exists.
|
|
#
|
|
# Create-only, never force-moved — a later rc.N finds the branch and
|
|
# no-ops. Moving it would silently re-open the freeze and drag in
|
|
# whatever the new tip contains, defeating the whole mechanism.
|
|
- name: Freeze the line (create release-X.Y)
|
|
id: freeze
|
|
if: steps.parse.outputs.kind == 'rc' && steps.parse.outputs.patch == '0'
|
|
env:
|
|
LINE: ${{ steps.parse.outputs.line }}
|
|
TAG: ${{ steps.parse.outputs.tag }}
|
|
run: |
|
|
BRANCH="release-$LINE"
|
|
# Same absent-vs-broken distinction the three guards above make: a
|
|
# transport/auth failure must not read as "branch not found" and send
|
|
# us into the create path.
|
|
set +e
|
|
existing_ls="$(git ls-remote --heads origin "refs/heads/$BRANCH")"
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo "::error::Could not query origin for '$BRANCH' (git ls-remote exit $rc) — refusing to proceed."
|
|
exit 1
|
|
fi
|
|
EXISTING="$(printf '%s' "$existing_ls" | cut -f1)"
|
|
if [ -n "$EXISTING" ]; then
|
|
echo "✅ $BRANCH already exists at $EXISTING; leaving it untouched."
|
|
echo "created=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# HEAD is the commit just tagged: the stale-tip guard above proved it
|
|
# still equals the remote branch tip, so branch and tag agree.
|
|
#
|
|
# This push and the tag push above are independent operations. If the
|
|
# tag landed and this fails (transient blip, runner eviction), the
|
|
# line is tagged but not frozen, and the write-once tag guard refuses
|
|
# a same-tag re-dispatch — so recovery is manual. Fail loudly with the
|
|
# exact command instead of leaving the operator to reconstruct it.
|
|
# Nothing is silently wrong in the meantime: no wrong ref is pushed,
|
|
# and finalize still creates the branch later.
|
|
if ! git push origin "HEAD:refs/heads/$BRANCH"; then
|
|
echo "::error::Failed to create $BRANCH, but $TAG is already pushed."
|
|
echo "::error::Re-dispatching $TAG will be refused by the write-once tag guard."
|
|
echo "::error::Recover manually: git push origin $(git rev-parse HEAD):refs/heads/$BRANCH"
|
|
exit 1
|
|
fi
|
|
echo "created=true" >> "$GITHUB_OUTPUT"
|
|
echo "🔒 Froze the $LINE line: created $BRANCH at $(git rev-parse HEAD) ($TAG)."
|
|
echo " 'main' is now open for the next minor."
|
|
echo " Fixes for $LINE must be cherry-picked onto $BRANCH."
|
|
|
|
# Give the new line its packages artifact straight away.
|
|
#
|
|
# build-release.yaml publishes `cozystack-packages:<line>` on push to a
|
|
# line branch, but it cannot fire for the push that CREATES the line: the
|
|
# branch points at a commit already on main, so the push carries no new
|
|
# commits, and GitHub does not run a workflow whose paths-ignore filter
|
|
# finds no changed files. Left alone, the line has no artifact until its
|
|
# first cherry-pick merges. In that window pull-requests.yaml's overlay
|
|
# finds nothing to pull and leaves each package on its committed ref —
|
|
# which at freeze time is the PREVIOUS release's, the cross-generation mix
|
|
# #3437 fixed. The old flow never had this window: it branched at the
|
|
# promote merge commit, which carried real commits and its own refs.
|
|
#
|
|
# Non-fatal by design. Without the artifact the overlay no-ops and early
|
|
# cherry-pick PRs test their committed refs — where they were before
|
|
# #3471, a degradation and not a broken release. The tag is pushed and the
|
|
# line is frozen by this point, so failing here would misreport both; warn
|
|
# with the manual command instead, as the freeze step does.
|
|
- name: Build the frozen line's packages artifact
|
|
if: steps.freeze.outputs.created == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
BRANCH: release-${{ steps.parse.outputs.line }}
|
|
run: |
|
|
if gh workflow run build-release.yaml --ref "$BRANCH"; then
|
|
echo "Dispatched build-release.yaml for $BRANCH; it publishes cozystack-packages:$BRANCH."
|
|
else
|
|
echo "::warning::Could not dispatch build-release.yaml for $BRANCH (the CI app may lack 'actions: write')."
|
|
echo "::warning::Until it runs, cozystack-packages:$BRANCH does not exist, and PRs onto $BRANCH test the image refs committed in the tree rather than the line's own build."
|
|
echo "::warning::Start it by hand: gh workflow run build-release.yaml --ref $BRANCH"
|
|
fi
|