mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-30 16:47:20 +09:00
ci(release): fix review findings; keep website docs at tag time
Dual-model review (Claude + Codex) of the previous two commits found seven substantive defects, two of them destructive. Both models independently reached three of them; the split is noted per item. Descope the website half (Claude, verified by reproduction). The docs job was moved to promote time, but the website Makefile pins the fetch ref with `override BRANCH := $(RELEASE_TAG)`, which defeats the command-line BRANCH assignment. `make -p` resolves BRANCH to v1.6.0 — a tag that cannot exist at promote time, since promote's own validate step hard-fails if it does. update_apps.sh logs the failed fetch and exits 0 AFTER truncating each destination file, so every app doc would become a stub, `changed=true` would open the PR, and the new auto-merge would land it unreviewed. Not fixable from this repo. update-website-docs returns to tags.yaml where RELEASE_TAG resolves to a real tag, and the finalize auto-merge is dropped. Opening it earlier needs a cozystack/website change first and is left as a follow-up. This also removes the unordered-sibling race Codex found, where the release PR could merge before the docs job opened its PR, leaving finalize nothing to merge and no retry. Fix the non-blocking guarantee (both models). Only the AI step carried continue-on-error; the changelog JOB did not, so a flaky `npm i -g @github/copilot` failed it and `needs` skipped open-pr entirely — staging branch pushed, release drafted, no promote PR. Gate open-pr on promote alone. Generate from a checkout the contract permits (both models). The job checked out the rc staging branch, which docs/agents/changelog.md §2 rejects — a compliant agent stops and writes nothing, so the normal path produced no changelog at all. Check out the rc tag detached and document that configuration in §2. The checkout ref now comes from the validated parse output rather than raw dispatch input. Port, don't regenerate, in the backstop (both models). For a patch release promoted onto release-X.Y the changelog never reaches main, so the backstop fired and ran a second AI pass; once its PR merged, update-releasenotes.yaml overwrote the published body with unreviewed text. Detect the file at the tag and port it verbatim; the AI runs only when it is absent from both main and the tag. Survive re-dispatch (Codex). promote force-pushes the staging branch, erasing a hand-written changelog — the documented recovery when generation fails. Preserve an existing one across the rebuild, and stop open-pr overwriting it with generated output. Validate structurally, not by size (Codex). A truncated or whitespace-only AI run passed `[ -s ]`, was committed, advertised as valid in the PR body, and published verbatim as release notes. Assert the header, the compare link and a plausible line count; finalize additionally refuses whitespace-only content rather than blanking the notes. Add hack/release-changelog-contract.bats — nine tests pinning each link in the chain, mutation-checked: removing open-pr's if:, breaking finalize's body read, and restoring paths-ignore each fail the suite. Update docs/release.md (~10 sites) and docs/agents/changelog.md §2, which described the old flow throughout. Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
6 files changed
+570
-194
No files matched your search
+103
-136
@@ -48,13 +48,14 @@ jobs:
|
||||
packages: write
|
||||
pull-requests: write
|
||||
|
||||
# Consumed by the changelog / open-pr / update-website-docs jobs below, so
|
||||
# the rc tag is parsed and validated exactly once.
|
||||
# Consumed by open-pr, so the rc tag is parsed and validated exactly once.
|
||||
# (rc_branch is deliberately not exported: the changelog job checks out the
|
||||
# rc *tag*, not the staging branch, and derives it from its own validated
|
||||
# parse — see the checkout comment in that job.)
|
||||
outputs:
|
||||
stable_tag: ${{ steps.parse.outputs.stable_tag }}
|
||||
stable_version: ${{ steps.parse.outputs.stable_version }}
|
||||
stable_branch: ${{ steps.parse.outputs.stable_branch }}
|
||||
rc_branch: ${{ steps.parse.outputs.rc_branch }}
|
||||
line: ${{ steps.parse.outputs.line }}
|
||||
|
||||
steps:
|
||||
@@ -192,8 +193,28 @@ jobs:
|
||||
# rc version, which was only observable by cutting a release.
|
||||
# The script fails if any rc reference survives it.
|
||||
hack/promote-rewrite-tags.sh "$RC_VERSION" "$STABLE_VERSION"
|
||||
# Preserve a changelog already committed on the stable staging branch
|
||||
# across the rebuild. -B below resets the branch to the rc tree, which
|
||||
# would otherwise silently discard it — and the two ways it gets there
|
||||
# are exactly the ones worth protecting: a maintainer hand-writing it
|
||||
# (the documented escape hatch when generation fails) and an earlier
|
||||
# dispatch's reviewed output. Losing either to a retry would replace
|
||||
# human-edited release notes with a fresh AI run, or with nothing.
|
||||
CL="docs/changelogs/v${STABLE_VERSION}.md"
|
||||
PRESERVED=""
|
||||
if git fetch -q origin "${STABLE_BRANCH}" 2>/dev/null \
|
||||
&& git cat-file -e "FETCH_HEAD:${CL}" 2>/dev/null; then
|
||||
PRESERVED="$(mktemp)"
|
||||
git show "FETCH_HEAD:${CL}" > "$PRESERVED"
|
||||
echo "::notice::Preserving existing ${CL} from ${STABLE_BRANCH} across the rebuild."
|
||||
fi
|
||||
# -B resets the local branch so a re-dispatch starts clean.
|
||||
git checkout -B "${STABLE_BRANCH}"
|
||||
if [ -n "$PRESERVED" ]; then
|
||||
mkdir -p docs/changelogs
|
||||
cp "$PRESERVED" "$CL"
|
||||
rm -f "$PRESERVED"
|
||||
fi
|
||||
git add -A
|
||||
git commit -s -m "Prepare release v${STABLE_VERSION} (promoted from ${RC_TAG})" \
|
||||
|| echo "No tag-string changes to commit (digests already stable)"
|
||||
@@ -305,15 +326,30 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
||||
# Re-emit the tag from the matched groups rather than passing
|
||||
# inputs.rc_tag through: the checkout `ref` below then consumes a value
|
||||
# this step reconstructed from a strict pattern, never raw dispatch
|
||||
# input, so a crafted rc_tag cannot reach `actions/checkout`.
|
||||
{
|
||||
echo "stable_version=${VERSION}"
|
||||
echo "rc_branch=release-${VERSION}-rc.${BASH_REMATCH[4]}"
|
||||
echo "rc_tag=v${VERSION}-rc.${BASH_REMATCH[4]}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout rc staging branch
|
||||
# Detached at the rc TAG, not the rc staging branch. Two reasons:
|
||||
#
|
||||
# 1. docs/agents/changelog.md §2 is a hard gate — it permits exactly two
|
||||
# working-tree states (on `main`, or detached at the release tag) and
|
||||
# instructs the agent to STOP otherwise. A staging-branch checkout is
|
||||
# neither, so a compliant agent would refuse to generate anything on the
|
||||
# normal path. §2 has been extended to name this configuration too.
|
||||
# 2. The staging branch carries digest-vendoring commits that are not
|
||||
# release content. Detaching at the tag makes "use the rc tag as the
|
||||
# upper bound" a property of the checkout rather than an instruction the
|
||||
# prompt has to talk the agent into honouring.
|
||||
- name: Checkout rc tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ steps.parse.outputs.rc_branch }}
|
||||
ref: ${{ steps.parse.outputs.rc_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token-read.outputs.token }}
|
||||
@@ -360,24 +396,43 @@ jobs:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token-read.outputs.token }}
|
||||
STABLE_VERSION: ${{ steps.parse.outputs.stable_version }}
|
||||
RC_TAG: ${{ inputs.rc_tag }}
|
||||
RC_TAG: ${{ steps.parse.outputs.rc_tag }}
|
||||
run: |
|
||||
copilot \
|
||||
--prompt "Generate the release changelog for the upcoming stable release v${STABLE_VERSION}, which is being promoted from the release candidate ${RC_TAG}. The repository is checked out on the rc staging branch. IMPORTANT: the v${STABLE_VERSION} tag does NOT exist yet — it is created only after the promotion PR merges. Use ${RC_TAG} as the upper bound in every git command (for example 'git log v<previous>..${RC_TAG}'), never HEAD and never the staging branch, because the staging branch carries digest-vendoring commits that are not part of the release content. Write the file as docs/changelogs/v${STABLE_VERSION}.md and use v${STABLE_VERSION} in the header, in the 'Full Changelog' link, and in the trailing release-link comment. Follow the instructions in @docs/agents/changelog.md exactly, including the 'Scope and boundaries' section at the top. Your deliverable is the single file docs/changelogs/v${STABLE_VERSION}.md — write it and exit; this workflow handles committing and opening the PR." \
|
||||
--prompt "Generate the release changelog for the upcoming stable release v${STABLE_VERSION}, which is being promoted from the release candidate ${RC_TAG}. The repository is checked out in detached HEAD at the ${RC_TAG} tag — the 'promotion' configuration in section 2 of docs/agents/changelog.md. IMPORTANT: the v${STABLE_VERSION} tag does NOT exist yet; it is created only after the promotion PR merges. Use ${RC_TAG} as the upper bound in every git command (for example 'git log v<previous>..${RC_TAG}'), never HEAD. Write the file as docs/changelogs/v${STABLE_VERSION}.md and use v${STABLE_VERSION} — not ${RC_TAG} — in the header, in the 'Full Changelog' link, and in the trailing release-link comment. Follow the instructions in @docs/agents/changelog.md exactly, including the 'Scope and boundaries' section at the top. Your deliverable is the single file docs/changelogs/v${STABLE_VERSION}.md — write it and exit; this workflow handles committing and opening the PR." \
|
||||
--allow-all-tools --allow-all-paths < /dev/null
|
||||
|
||||
# Structural validation, not just "non-empty". The AI step is allowed to
|
||||
# fail, and a run that dies partway (timeout, quota exhaustion mid-stream)
|
||||
# can leave a fragment or a few bytes of whitespace behind. A bare `-s`
|
||||
# test would call that present, commit it, advertise it as valid in the PR
|
||||
# body, and finalize would publish it verbatim as the release notes. Assert
|
||||
# the markers a real changelog always has instead; anything failing them is
|
||||
# treated exactly like "not generated" and takes the degraded path.
|
||||
- name: Verify changelog
|
||||
id: changelog_status
|
||||
env:
|
||||
STABLE_VERSION: ${{ steps.parse.outputs.stable_version }}
|
||||
run: |
|
||||
if [ -s "docs/changelogs/v${STABLE_VERSION}.md" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ docs/changelogs/v${STABLE_VERSION}.md present ($(wc -l < "docs/changelogs/v${STABLE_VERSION}.md") lines)."
|
||||
else
|
||||
CL="docs/changelogs/v${STABLE_VERSION}.md"
|
||||
fail() {
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::Changelog generation did not produce docs/changelogs/v${STABLE_VERSION}.md. The promote PR will open WITHOUT a changelog — commit it to the PR branch before merging, or the published release will have empty notes."
|
||||
fi
|
||||
echo "::warning::Changelog not usable ($1). The promote PR will open WITHOUT a changelog — commit docs/changelogs/v${STABLE_VERSION}.md to the PR branch before merging, or the published release will have empty notes."
|
||||
exit 0
|
||||
}
|
||||
[ -f "$CL" ] || fail "file absent"
|
||||
[ -s "$CL" ] || fail "file empty"
|
||||
# Non-whitespace content
|
||||
grep -q '[^[:space:]]' "$CL" || fail "file is only whitespace"
|
||||
# A truncated stream usually loses the tail, so check both ends.
|
||||
grep -qE "^# Cozystack v${STABLE_VERSION}\b" "$CL" \
|
||||
|| fail "missing '# Cozystack v${STABLE_VERSION}' header"
|
||||
grep -qF "compare/" "$CL" \
|
||||
|| fail "missing the Full Changelog compare link (likely truncated)"
|
||||
LINES=$(wc -l < "$CL")
|
||||
[ "$LINES" -ge 20 ] || fail "only ${LINES} lines — implausibly short for a release changelog"
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ $CL validated (${LINES} lines)."
|
||||
|
||||
# Hand the file to `open-pr`, which owns every write to the staging branch.
|
||||
- name: Upload changelog artifact
|
||||
@@ -396,6 +451,16 @@ jobs:
|
||||
name: Open promote PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: [promote, changelog]
|
||||
# `needs` alone would SKIP this job whenever `changelog` fails, which would
|
||||
# leave the staging branch pushed and the draft release staged but no promote
|
||||
# PR at all — the exact wedge the non-blocking design exists to prevent.
|
||||
# Only the AI *step* carries continue-on-error; everything else in that job
|
||||
# (token, checkout, node setup, `npm i -g @github/copilot`, artifact upload,
|
||||
# the job timeout) is still fatal, and an npm or Copilot outage is precisely
|
||||
# the scenario this must survive. Gate on `promote` alone and let the
|
||||
# `needs.changelog.outputs.present` guards below degrade to the documented
|
||||
# "PR opens with the ⚠️ body" path.
|
||||
if: ${{ !cancelled() && needs.promote.result == 'success' }}
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -421,15 +486,33 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# A changelog already on the staging branch was put there deliberately —
|
||||
# hand-written by a maintainer, or carried over from an earlier dispatch by
|
||||
# `promote`'s preserve step — and must win over a fresh AI run. Without this
|
||||
# check the artifact download below would silently overwrite human edits.
|
||||
- name: Check for an existing changelog on the branch
|
||||
id: existing
|
||||
env:
|
||||
STABLE_VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
run: |
|
||||
if [ -s "docs/changelogs/v${STABLE_VERSION}.md" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Changelog already on the staging branch — keeping it, not overwriting with generated output."
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download changelog artifact
|
||||
if: needs.changelog.outputs.present == 'true'
|
||||
if: needs.changelog.outputs.present == 'true' && steps.existing.outputs.present == 'false'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: changelog-v${{ needs.promote.outputs.stable_version }}
|
||||
path: docs/changelogs
|
||||
|
||||
# Only when the file came from the artifact. A changelog already on the
|
||||
# branch was committed by `promote` (preserve path) and needs no second commit.
|
||||
- name: Commit changelog to staging branch
|
||||
if: needs.changelog.outputs.present == 'true'
|
||||
if: needs.changelog.outputs.present == 'true' && steps.existing.outputs.present == 'false'
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
STABLE_VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
@@ -456,7 +539,10 @@ jobs:
|
||||
STABLE_VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
RC_TAG: ${{ inputs.rc_tag }}
|
||||
LINE: ${{ needs.promote.outputs.line }}
|
||||
CHANGELOG_PRESENT: ${{ needs.changelog.outputs.present }}
|
||||
# True when the PR carries a changelog by EITHER route: preserved on the
|
||||
# branch, or generated this run. The PR body must reflect what merged,
|
||||
# not just what the AI produced.
|
||||
CHANGELOG_PRESENT: ${{ (steps.existing.outputs.present == 'true' || needs.changelog.outputs.present == 'true') && 'true' || 'false' }}
|
||||
run: |
|
||||
# Target the maintenance line if it exists, else main.
|
||||
BASE=main
|
||||
@@ -483,122 +569,3 @@ jobs:
|
||||
--title "chore(release): promote ${RC_TAG} -> ${STABLE_TAG}" \
|
||||
--body "$BODY" --label release --label full-e2e
|
||||
fi
|
||||
|
||||
# Website docs, opened at promote time rather than at stable-tag push, for the
|
||||
# same reason as the changelog: at tag push the release is already public.
|
||||
# Different repository, so it cannot ride the promote PR — it can only be
|
||||
# opened early. pull-requests-release.yaml finalize auto-merges it once the
|
||||
# release is published, so the site never lags the release.
|
||||
#
|
||||
# Depends only on `promote` (it reads the release-X.Y.Z branch that step
|
||||
# pushed), so it runs concurrently with `changelog` and `open-pr`.
|
||||
update-website-docs:
|
||||
name: Update Website Docs
|
||||
runs-on: ubuntu-latest
|
||||
needs: [promote]
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: 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 website repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: cozystack/website
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
|
||||
# Decide whether this release promotes the `next/` trunk to a new released
|
||||
# version directory. Per the website repo's contract (see website#495):
|
||||
# - `make release-next` runs only for new minor/major final releases
|
||||
# (`vX.Y/` does not yet exist on disk).
|
||||
# - Patch releases skip this step and let `make update-all` route on
|
||||
# their own.
|
||||
- name: Determine if this release promotes next/
|
||||
id: promote_next
|
||||
env:
|
||||
STABLE_TAG: ${{ needs.promote.outputs.stable_tag }}
|
||||
run: |
|
||||
if [[ ! "$STABLE_TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Non-stable tag '$STABLE_TAG' — skipping release-next."
|
||||
exit 0
|
||||
fi
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
if [[ "$MAJOR" == "0" ]]; then
|
||||
DOC_VERSION="v0"
|
||||
else
|
||||
DOC_VERSION="v${MAJOR}.${MINOR}"
|
||||
fi
|
||||
if [[ -d "content/en/docs/${DOC_VERSION}" ]]; then
|
||||
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||
echo "content/en/docs/${DOC_VERSION}/ already exists — patch release, skipping release-next."
|
||||
else
|
||||
echo "promote=true" >> "$GITHUB_OUTPUT"
|
||||
echo "New minor/major release ${DOC_VERSION} — will promote next/ → ${DOC_VERSION}/."
|
||||
fi
|
||||
|
||||
- name: Promote next/ to released version
|
||||
if: steps.promote_next.outputs.promote == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ needs.promote.outputs.stable_tag }}
|
||||
run: make release-next RELEASE_TAG="$TAG"
|
||||
|
||||
# BRANCH is the release-X.Y.Z staging branch `promote` just pushed — the
|
||||
# promoted stable tree. At stable-tag push this used to read the same
|
||||
# branch, so the input is unchanged; only the timing moved earlier.
|
||||
- name: Update docs from release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ needs.promote.outputs.stable_tag }}
|
||||
VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
run: make update-all BRANCH="release-$VERSION" RELEASE_TAG="$TAG"
|
||||
|
||||
- name: Commit and push
|
||||
id: commit
|
||||
env:
|
||||
VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
run: |
|
||||
git config user.name "cozystack-ci[bot]"
|
||||
git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com"
|
||||
git add content hugo.yaml data/versions
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
BRANCH="update-docs-v${VERSION}"
|
||||
git branch -D "$BRANCH" 2>/dev/null || true
|
||||
git checkout -b "$BRANCH"
|
||||
git commit --signoff -m "[docs] Update managed apps reference for v${VERSION}"
|
||||
git push --force --set-upstream origin "$BRANCH"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open pull request
|
||||
if: steps.commit.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VERSION: ${{ needs.promote.outputs.stable_version }}
|
||||
STABLE_TAG: ${{ needs.promote.outputs.stable_tag }}
|
||||
run: |
|
||||
BRANCH="update-docs-v${VERSION}"
|
||||
pr_state=$(gh pr view "$BRANCH" --repo cozystack/website --json state --jq .state 2>/dev/null || echo "")
|
||||
if [[ "$pr_state" == "OPEN" ]]; then
|
||||
echo "PR already open, skipping creation."
|
||||
else
|
||||
gh pr create \
|
||||
--repo cozystack/website \
|
||||
--title "[docs] Update managed apps reference for v${VERSION}" \
|
||||
--body "Automated docs update for release \`${STABLE_TAG}\`. Opened at promotion time, before the release is published; pull-requests-release.yaml auto-merges this once \`${STABLE_TAG}\` goes live." \
|
||||
--head "$BRANCH" \
|
||||
--base main
|
||||
fi
|
||||
@@ -336,8 +336,18 @@ jobs:
|
||||
const changelogPath = `docs/changelogs/${tag}.md`;
|
||||
let body;
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
body = fs.readFileSync(changelogPath, 'utf8');
|
||||
console.log(`📝 Release body from ${changelogPath} (${body.length} bytes)`);
|
||||
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.`);
|
||||
}
|
||||
@@ -441,28 +451,3 @@ jobs:
|
||||
"docker://${REGISTRY}/cozy-installer:${STABLE_VERSION}" \
|
||||
"docker://${REGISTRY}/cozy-installer:latest"
|
||||
fi
|
||||
|
||||
# Land the website docs PR that promote-rc.yaml opened before the release
|
||||
# existed. Merged LAST, once the tag, the release and every registry side
|
||||
# effect above have succeeded — so the site is never updated for a release
|
||||
# that failed to ship. Stable releases only: an rc never opens a docs PR.
|
||||
#
|
||||
# Best-effort by design. The docs PR living in another repository means it
|
||||
# can be closed, retargeted or merged by hand between promotion and merge;
|
||||
# none of those should fail a release that is already fully published and
|
||||
# irreversible at this point.
|
||||
- name: Merge website docs PR
|
||||
if: ${{ !contains(steps.get_tag.outputs.tag, '-') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
VTAG: ${{ steps.get_tag.outputs.tag }}
|
||||
run: |
|
||||
BRANCH="update-docs-${VTAG}"
|
||||
state="$(gh pr view "$BRANCH" --repo cozystack/website --json state --jq .state 2>/dev/null || echo "")"
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "No open website docs PR for ${BRANCH} (state: ${state:-none}) — nothing to merge."
|
||||
exit 0
|
||||
fi
|
||||
gh pr merge "$BRANCH" --repo cozystack/website --squash --delete-branch
|
||||
echo "✅ Merged cozystack/website PR for ${BRANCH}."
|
||||
+168
-9
@@ -396,11 +396,20 @@ jobs:
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# Check whether the changelog already exists on origin/main.
|
||||
# The previous step checked out the tag commit, which never has the
|
||||
# changelog (the changelog PR merges to main AFTER the tag is cut).
|
||||
# Looking at origin/main is what the gate is supposed to do — skip
|
||||
# generation on workflow reruns when the changelog has already merged.
|
||||
# Two independent questions, and the answers select between three paths.
|
||||
#
|
||||
# `exists` — is the changelog already on origin/main? Then this whole job
|
||||
# is a no-op. That is the normal outcome now that promote-rc.yaml puts it
|
||||
# there via the promote PR.
|
||||
#
|
||||
# `at_tag` — is it present in THIS checkout (the tag commit)? Under the
|
||||
# promote flow it usually is, because the promote PR carried it. When that
|
||||
# PR targeted a maintenance line the file is at the tag but never reached
|
||||
# main, so `exists` is false while a reviewed, already-published changelog
|
||||
# sits right here — port it rather than regenerate.
|
||||
#
|
||||
# Only when both are false is there genuinely no changelog to publish, and
|
||||
# only then does the AI run.
|
||||
- name: Check if changelog already exists
|
||||
id: check_changelog
|
||||
env:
|
||||
@@ -415,19 +424,36 @@ jobs:
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Changelog file $CHANGELOG_FILE does not exist on origin/main"
|
||||
fi
|
||||
# Is the changelog present at the TAG (this checkout)? Under the promote
|
||||
# flow it normally is: promote-rc.yaml commits it onto release-X.Y.Z and
|
||||
# it reaches the tag via the merge commit. When the promote PR targeted a
|
||||
# maintenance line (a patch release), the file lands on release-X.Y and
|
||||
# never reaches main — so `exists` above is false even though a reviewed,
|
||||
# already-PUBLISHED changelog exists right here.
|
||||
#
|
||||
# Regenerating in that case would spend a second AI run and, once the
|
||||
# backstop PR merged, update-releasenotes.yaml would overwrite the
|
||||
# published release body with text nobody approved. Port the reviewed
|
||||
# file verbatim instead; the AI is only for the genuinely-absent case.
|
||||
if [ -s "$CHANGELOG_FILE" ]; then
|
||||
echo "at_tag=true" >> $GITHUB_OUTPUT
|
||||
echo "Changelog present at the tag — will port it to main verbatim, no regeneration."
|
||||
else
|
||||
echo "at_tag=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check_changelog.outputs.exists == 'false'
|
||||
if: steps.check_changelog.outputs.exists == 'false' && steps.check_changelog.outputs.at_tag == 'false'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install GitHub Copilot CLI
|
||||
if: steps.check_changelog.outputs.exists == 'false'
|
||||
if: steps.check_changelog.outputs.exists == 'false' && steps.check_changelog.outputs.at_tag == 'false'
|
||||
run: npm i -g @github/copilot
|
||||
|
||||
- name: Generate changelog using AI
|
||||
if: steps.check_changelog.outputs.exists == 'false'
|
||||
if: steps.check_changelog.outputs.exists == 'false' && steps.check_changelog.outputs.at_tag == 'false'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
@@ -449,8 +475,11 @@ jobs:
|
||||
CHANGELOG_FILE="docs/changelogs/v${VERSION}.md"
|
||||
CHANGELOG_BRANCH="changelog-v${VERSION}"
|
||||
|
||||
# Two ways the file can be here: ported verbatim from the tag commit
|
||||
# (at_tag=true — the reviewed, already-published changelog reaching main
|
||||
# for the first time), or freshly generated above. Either is valid.
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::error::Changelog file $CHANGELOG_FILE was not produced by the Generate changelog using AI step"
|
||||
echo "::error::Changelog file $CHANGELOG_FILE is absent — it was neither present at the tag nor produced by the Generate changelog using AI step"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -s "$CHANGELOG_FILE" ]; then
|
||||
@@ -537,3 +566,133 @@ jobs:
|
||||
console.log(`Created PR #${pr.data.number} for changelog`);
|
||||
}
|
||||
|
||||
|
||||
update-website-docs:
|
||||
name: Update Website Docs
|
||||
# bash + git + yq + gh against the website repo — no docker: GitHub-hosted.
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-changelog, prepare-release]
|
||||
# generate-changelog is non-blocking — run as long as prepare-release succeeded.
|
||||
# `!cancelled()` lets this job run if generate-changelog failed/skipped,
|
||||
# while still stopping it when the workflow is cancelled.
|
||||
# Not gated on release_exists: a promoted stable tag already has a draft
|
||||
# release (so prepare-release skips the rebuild), but its website docs still
|
||||
# need publishing. Gating on release_exists here skipped docs for every
|
||||
# promoted stable.
|
||||
# Stable tags only — a prerelease (ref_name contains '-') would otherwise open
|
||||
# an "update managed apps for vX.Y.Z-rc.N" website PR on every rc.
|
||||
if: ${{ !cancelled() && needs.prepare-release.result == 'success' && !contains(github.ref_name, '-') }}
|
||||
permissions:
|
||||
contents: 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: Parse tag
|
||||
id: tag
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const ref = context.ref.replace('refs/tags/', '');
|
||||
const m = ref.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/);
|
||||
if (!m) {
|
||||
core.setFailed(`❌ tag '${ref}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`);
|
||||
return;
|
||||
}
|
||||
const version = m[1] + (m[2] ?? '');
|
||||
core.setOutput('tag', ref); // v0.22.0
|
||||
core.setOutput('version', version); // 0.22.0
|
||||
|
||||
- name: Checkout website repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: cozystack/website
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
ref: main
|
||||
|
||||
# Decide whether this release promotes the `next/` trunk to a new released
|
||||
# version directory. Per the website repo's contract (see website#495):
|
||||
# - `make release-next` runs only for new minor/major final releases
|
||||
# (tag matches `vX.Y.Z` with no prerelease suffix AND `vX.Y/` does
|
||||
# not yet exist on disk).
|
||||
# - Prereleases and patch releases skip this step and let
|
||||
# `make update-all` handle routing on its own.
|
||||
- name: Determine if this release promotes next/
|
||||
id: promote
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
if [[ ! "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Prerelease tag '$TAG' — skipping release-next."
|
||||
exit 0
|
||||
fi
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
if [[ "$MAJOR" == "0" ]]; then
|
||||
DOC_VERSION="v0"
|
||||
else
|
||||
DOC_VERSION="v${MAJOR}.${MINOR}"
|
||||
fi
|
||||
if [[ -d "content/en/docs/${DOC_VERSION}" ]]; then
|
||||
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||
echo "content/en/docs/${DOC_VERSION}/ already exists — patch release, skipping release-next."
|
||||
else
|
||||
echo "promote=true" >> "$GITHUB_OUTPUT"
|
||||
echo "New minor/major release ${DOC_VERSION} — will promote next/ → ${DOC_VERSION}/."
|
||||
fi
|
||||
|
||||
- name: Promote next/ to released version
|
||||
if: steps.promote.outputs.promote == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: make release-next RELEASE_TAG="$TAG"
|
||||
|
||||
- name: Update docs from release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
VERSION: ${{ steps.tag.outputs.version }}
|
||||
run: make update-all BRANCH="release-$VERSION" RELEASE_TAG="$TAG"
|
||||
|
||||
- name: Commit and push
|
||||
id: commit
|
||||
run: |
|
||||
git config user.name "cozystack-ci[bot]"
|
||||
git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com"
|
||||
git add content hugo.yaml data/versions
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
BRANCH="update-docs-v${{ steps.tag.outputs.version }}"
|
||||
git branch -D "$BRANCH" 2>/dev/null || true
|
||||
git checkout -b "$BRANCH"
|
||||
git commit --signoff -m "[docs] Update managed apps reference for v${{ steps.tag.outputs.version }}"
|
||||
git push --force --set-upstream origin "$BRANCH"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Open pull request
|
||||
if: steps.commit.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
BRANCH="update-docs-v${{ steps.tag.outputs.version }}"
|
||||
pr_state=$(gh pr view "$BRANCH" --repo cozystack/website --json state --jq .state 2>/dev/null || echo "")
|
||||
if [[ "$pr_state" == "OPEN" ]]; then
|
||||
echo "PR already open, skipping creation."
|
||||
else
|
||||
gh pr create \
|
||||
--repo cozystack/website \
|
||||
--title "[docs] Update managed apps reference for v${{ steps.tag.outputs.version }}" \
|
||||
--body "Automated docs update for release \`v${{ steps.tag.outputs.version }}\`." \
|
||||
--head "update-docs-v${{ steps.tag.outputs.version }}" \
|
||||
--base main
|
||||
fi
|
||||
@@ -54,18 +54,23 @@ This is necessary to get up-to-date information about tags and commits from the
|
||||
|
||||
### 2. Checking current branch
|
||||
|
||||
Verify the working tree is at a sensible starting point. Two configurations are valid:
|
||||
Verify the working tree is at a sensible starting point. Three configurations are valid:
|
||||
|
||||
- **Interactive use:** the working tree is on the `main` branch.
|
||||
```bash
|
||||
git branch --show-current # should print "main"
|
||||
```
|
||||
- **CI use:** HEAD is detached at the release tag being generated (this is how `.github/workflows/tags.yaml` runs).
|
||||
- **CI use (backstop):** HEAD is detached at the release tag being generated (this is how `.github/workflows/tags.yaml` runs).
|
||||
```bash
|
||||
git describe --exact-match HEAD # should print e.g. "v1.3.1"
|
||||
```
|
||||
- **CI use (promotion):** HEAD is detached at the **release-candidate** tag being promoted, while the changelog you are asked to write is for the *stable* version that candidate becomes (this is how `.github/workflows/promote-rc.yaml` runs, and it is the normal path — the stable tag does not exist yet at that point).
|
||||
```bash
|
||||
git describe --exact-match HEAD # should print e.g. "v1.3.1-rc.2"
|
||||
```
|
||||
In this configuration the caller tells you both versions. Use the rc tag as the upper bound of every commit range, and write the *stable* version into the filename, the header, the "Full Changelog" link, and the trailing release-link comment. Never write the rc tag into the changelog's own contents.
|
||||
|
||||
If neither holds, stop — the caller invoked you from the wrong place. Do **not** switch branches yourself; the "Scope and boundaries" section forbids it.
|
||||
If none holds, stop — the caller invoked you from the wrong place. Do **not** switch branches yourself; the "Scope and boundaries" section forbids it.
|
||||
|
||||
### 3. Determining release type and previous version
|
||||
|
||||
|
||||
+30
-19
@@ -60,7 +60,8 @@ A regular release sequence runs as follows:
|
||||
3. Once an rc is green, run the [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) workflow (manual dispatch) with that rc tag. Promotion is **transactional** — it performs no registry mutation at dispatch. It:
|
||||
1. Pushes the `release-1.2.0` branch: the rc's digest-vendored tree with the rc version substring rewritten to `1.2.0` (the digests are the rc's — unchanged).
|
||||
2. Drafts the `v1.2.0` release and uploads the (restamped) assets.
|
||||
3. Opens the `chore(release): promote v1.2.0-rc.N -> v1.2.0` PR into `main`, labelled `release` and `full-e2e` (the latter forces the full e2e suite on the PR). The retag itself is deferred to the merge (step 5) — so an abandoned promotion leaves no stable-named images and cannot wedge a re-promotion.
|
||||
3. Generates `docs/changelogs/v1.2.0.md` (in a parallel job, detached at the rc tag) and commits it onto the `release-1.2.0` branch, so the changelog is reviewed as part of the promotion and finalize can publish the release with it already in place. Non-blocking: if generation fails the PR still opens, carrying a ⚠️ line that says the changelog is missing.
|
||||
4. Opens the `chore(release): promote v1.2.0-rc.N -> v1.2.0` PR into `main`, labelled `release` and `full-e2e` (the latter forces the full e2e suite on the PR). The retag itself is deferred to the merge (step 5) — so an abandoned promotion leaves no stable-named images and cannot wedge a re-promotion.
|
||||
|
||||
```mermaid
|
||||
gitGraph
|
||||
@@ -79,7 +80,7 @@ A regular release sequence runs as follows:
|
||||
1. Creates the tag `v1.2.0` at the newly created merge commit — write-once. The tag is published here for the first time, never moved.
|
||||
2. Cuts the write-once `api/apps/v1alpha1/v1.2.0` Go-module tag at the same commit.
|
||||
3. Ensures the `release-1.2` maintenance branch exists at the tag commit.
|
||||
4. Publishes the release page (`draft` → `latest`).
|
||||
4. Publishes the release page (`draft` → `latest`), using `docs/changelogs/v1.2.0.md` from the merge commit as the release body.
|
||||
5. Retags the rc's images by digest to `v1.2.0` (and `:latest` only when `v1.2.0` is the newest published stable), and publishes the stable `cozy-installer` chart — no rebuild.
|
||||
6. The maintainer can now announce the release to the community.
|
||||
|
||||
@@ -179,10 +180,10 @@ gitGraph
|
||||
The numbered process above is implemented by five workflows. Knowing which job does what makes the failure modes much easier to diagnose.
|
||||
|
||||
1. [`cut-prerelease.yaml`](../.github/workflows/cut-prerelease.yaml) — manual dispatch (from `main` or `release-X.Y`): the sole entry point for creating a pre-release tag. Validates the `-alpha`/`-beta`/`-rc` tag, then pushes it (write-once, at the branch tip) as the CI app so `tags.yaml` fires. Stable tags are never created here.
|
||||
2. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `generate-changelog`, then `update-website-docs`.
|
||||
3. [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) — manual dispatch: stages the `release-X.Y.Z` tree (rc digests, tag string rewritten to stable), drafts the stable release, and opens the `release-X.Y.Z` promote PR. No registry mutation — transactional.
|
||||
4. [`pull-requests-release.yaml`](../.github/workflows/pull-requests-release.yaml) — fires when the `release-X.Y.Z` PR merges; finalizes the release: cuts the write-once stable + Go-module tags, publishes, then retags the rc images to stable by digest (`:latest` gated on newest-stable) and publishes the stable chart.
|
||||
5. [`update-releasenotes.yaml`](../.github/workflows/update-releasenotes.yaml) — fires on every push to `main`; syncs `docs/changelogs/v*.md` content into the corresponding GitHub Release body.
|
||||
2. [`tags.yaml`](../.github/workflows/tags.yaml) — fires on an rc tag push: runs `prepare-release`, then `update-website-docs`. `generate-changelog` also lives here but is a **backstop only** — the changelog is normally produced at promotion time (see 3), and this job self-skips when it is already on `main`.
|
||||
3. [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) — manual dispatch. Three jobs: `promote` stages the `release-X.Y.Z` tree (rc digests, tag string rewritten to stable) and drafts the stable release; `changelog` generates `docs/changelogs/vX.Y.Z.md` **in parallel**; `open-pr` joins them, commits the changelog onto the staging branch, and opens the `release-X.Y.Z` promote PR. So the changelog is reviewed as part of the promotion, and is on the base branch before the release is ever published. No registry mutation — transactional.
|
||||
4. [`pull-requests-release.yaml`](../.github/workflows/pull-requests-release.yaml) — fires when the `release-X.Y.Z` PR merges; finalizes the release: cuts the write-once stable + Go-module tags, publishes the release **with the merged changelog as its body**, then retags the rc images to stable by digest (`:latest` gated on newest-stable) and publishes the stable chart.
|
||||
5. [`update-releasenotes.yaml`](../.github/workflows/update-releasenotes.yaml) — fires on pushes to `main` that touch `docs/changelogs/v*.md`; syncs that content into the corresponding GitHub Release body. No longer the primary path (finalize sets the body directly, which is race-free and works for maintenance lines too) — this now covers later edits to a published changelog and manual re-syncs via `workflow_dispatch`.
|
||||
|
||||
### Phase 1 — `prepare-release` (hard gate)
|
||||
|
||||
@@ -202,17 +203,27 @@ Things that look surprising in the diff but are normal:
|
||||
- **Switched registry**: if a self-built image moved from `docker.io` to `ghcr.io`, the registry portion of the digest changes — that's a deliberate move, not a regression.
|
||||
- **`targetVersion` is NOT touched by the release PR.** Platform migration `targetVersion` is bumped earlier in a feature commit (e.g. `[platform] Bump migration targetVersion to 39 for migration 38`). The release PR only re-pins the `platform-migrations` image digest.
|
||||
|
||||
### Phase 2 — `generate-changelog` (non-blocking)
|
||||
### Phase 2 — the changelog (generated at promotion, not here)
|
||||
|
||||
Uses a separate **read-only** GitHub App token (`permission-contents: read`, `permission-pull-requests: read`, `permission-metadata: read`) for the AI step, so the model cannot mutate the repo even with `--allow-all-tools`. Branch creation, commit, push, and PR creation happen in subsequent steps under a write-scoped token.
|
||||
The changelog for `vX.Y.Z` is produced by [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml)'s `changelog` job and committed onto the `release-X.Y.Z` staging branch by `open-pr`, so it rides the promote PR and is on the base branch the moment the release publishes. Finalize then uses that file verbatim as the GitHub Release body. Generating it here — on the stable tag push — would be too late by construction: that tag is created by finalize, which has already published the release.
|
||||
|
||||
The job runs with `continue-on-error: true`. A green tag CI run does **not** imply a changelog landed — check that the `changelog-vX.Y.Z` PR exists with content. If it does not, follow [`agents/changelog.md`](./agents/changelog.md) manually.
|
||||
The `changelog` job runs **in parallel** with `promote`, detached at the rc tag (the "promotion" configuration in §2 of [`agents/changelog.md`](./agents/changelog.md)), using a separate **read-only** GitHub App token for the AI step so the model cannot mutate the repo even with `--allow-all-tools`. Every write — the commit onto the staging branch and the PR — happens in `open-pr` under a write-scoped token.
|
||||
|
||||
Known reasons this phase fails silently:
|
||||
Generation is deliberately **non-blocking**: a Copilot outage or exhausted token quota must never block a release. Only the AI step carries `continue-on-error`, and `open-pr` is gated on `promote` alone (`if: !cancelled() && needs.promote.result == 'success'`) so the promote PR opens regardless. When the changelog is missing the PR body says so explicitly — that ⚠️ line is the signal to act on.
|
||||
|
||||
- Copilot quota exhausted (`COPILOT_GITHUB_TOKEN` 402). Refill or rotate. Drafts still get created by Phase 1; you have to open the changelog PR by hand.
|
||||
Recovery, in order of preference:
|
||||
|
||||
1. Commit `docs/changelogs/vX.Y.Z.md` to the `release-X.Y.Z` branch by hand (follow [`agents/changelog.md`](./agents/changelog.md)). `promote` preserves it across a re-dispatch, and `open-pr` will not overwrite it with generated output.
|
||||
2. Re-dispatch `promote-rc.yaml` — generation runs again; anything already on the branch wins.
|
||||
3. Merge without one and let `tags.yaml::generate-changelog` (the backstop) open a changelog PR against `main` after the fact. The release is published with the draft's body until that merges.
|
||||
|
||||
Known failure modes:
|
||||
|
||||
- Copilot quota exhausted (`COPILOT_GITHUB_TOKEN` 402). Refill or rotate, then re-dispatch.
|
||||
- AI step timeout (hard 30-min cap).
|
||||
- Output file empty — caught by the next step's `[ -s ]` check, fails loudly.
|
||||
- Output truncated or whitespace-only — caught by the `Verify changelog` step, which asserts the `# Cozystack vX.Y.Z` header, the compare link, and a plausible line count, and downgrades to the missing-changelog path rather than shipping a fragment as release notes.
|
||||
|
||||
**`tags.yaml::generate-changelog` is the backstop.** It self-skips when the changelog is already on `main`. It earns its keep in two cases: generation failed during promotion, or the promote PR targeted `release-X.Y` (a patch release) so the file never reached `main`. In the second case it **ports the reviewed file from the tag commit verbatim** rather than regenerating — a second AI pass there would spend quota and then overwrite already-published, already-reviewed release notes.
|
||||
|
||||
### Phase 3 — `update-website-docs`
|
||||
|
||||
@@ -228,7 +239,7 @@ Reviewer checklist:
|
||||
- [ ] No accidental config drift (a value file you don't recognize).
|
||||
- [ ] If any chart bumped, check the new digest pulls — `crane manifest ghcr.io/cozystack/cozystack/<name>@<digest>` should return.
|
||||
- [ ] Build artifacts on the draft release page exist and match the expected count.
|
||||
- [ ] Changelog PR exists, has content, and the entries match the commit range (see [Changelog pre-publish verification](#changelog-pre-publish-verification)).
|
||||
- [ ] The PR includes `docs/changelogs/vX.Y.Z.md`, it has content, and the entries match the commit range (see [Changelog pre-publish verification](#changelog-pre-publish-verification)). If the PR body carries the ⚠️ "No changelog in this PR" line, resolve that before merging — finalize publishes this file as the release body.
|
||||
|
||||
### Phase 5 — `pull-requests-release.yaml` (finalize)
|
||||
|
||||
@@ -237,12 +248,12 @@ Fires on merge of a PR that is merged, carries the `release` label, and is autho
|
||||
1. **Create the tag at the merge commit** (write-once). The merge commit of `Prepare release vX.Y.Z` did not exist before the PR opened, so there is nothing to move — the tag is created here for the first time. A pre-existing tag at a different commit fails the step loudly rather than being force-moved (see [Tag immutability](#tag-immutability)).
|
||||
2. **Cut the `api/apps/v1alpha1/vX.Y.Z` Go-module tag** (write-once, stable only) at the same commit, so Go consumers of `api/apps/v1alpha1` get the release. Moved here from `tags.yaml`, whose `prepare-release` body is skipped for a promoted stable (the draft already exists).
|
||||
3. **Ensure the maintenance branch `release-X.Y` exists** at the tag commit. Created if missing; updated fast-forward-only — a non-fast-forward update warns and is left for a maintainer rather than being force-updated.
|
||||
4. **Publish the draft release.** `make_latest` is computed against published-non-prerelease tags: prereleases stay `false`; tags older than the current max stay `false` (and the current max is force-restored to `latest` if necessary, so an older patch tag cut after a newer minor won't downgrade `latest`).
|
||||
4. **Publish the draft release**, with `docs/changelogs/vX.Y.Z.md` from the merge commit as its body (an absent or whitespace-only file warns and leaves the draft's body in place, rather than publishing blank notes). `make_latest` is computed against published-non-prerelease tags: prereleases stay `false`; tags older than the current max stay `false` (and the current max is force-restored to `latest` if necessary, so an older patch tag cut after a newer minor won't downgrade `latest`).
|
||||
5. **Retag the rc images to stable** by digest (`hack/promote-retag.sh`, no rebuild) and **publish the stable `cozy-installer` chart**. `:latest` (on both the images and the chart) moves only when this release's `make_latest` was `true` — the same decision as step 4, so the release's `latest` and the images' `:latest` never disagree. The chart is packaged with `platformVersion` stamped into its default values so the documented `helm --install --version X.Y.Z` path reports the stable version.
|
||||
|
||||
### Phase 6 — `update-releasenotes.yaml` (sync GitHub Release body)
|
||||
|
||||
Fires on every push to `main`. Reads each `docs/changelogs/vX.Y.Z.md` and PATCHes the matching GitHub Release's `body` if it differs. So edits to a published changelog file land on the GitHub Release on the next push to `main`, without re-running the release flow.
|
||||
Fires on pushes to `main` that touch `docs/changelogs/v*.md`. Reads each `docs/changelogs/vX.Y.Z.md` and PATCHes the matching GitHub Release's `body` if it differs. Since Phase 5 now sets the body directly from the merge commit, this is no longer on the critical path — it covers later **edits** to a published changelog, the backstop PR landing a changelog that reached `main` after the fact, and manual re-syncs via `workflow_dispatch`. It only watches `main`, which is why finalize (not this job) is what populates a patch release promoted onto `release-X.Y`.
|
||||
|
||||
## Stable tags come from rc promotion
|
||||
|
||||
@@ -374,7 +385,7 @@ This rule generalizes to any monotonic-counter state: schema versions, feature-f
|
||||
|
||||
### Where the canonical process lives
|
||||
|
||||
[`agents/changelog.md`](./agents/changelog.md) is the source of truth. Read it end-to-end before generating; do not infer the process from past commits or memory. The CI runs it under Copilot via `tags.yaml::generate-changelog`; locally the same prompt is replayable by following the doc directly.
|
||||
[`agents/changelog.md`](./agents/changelog.md) is the source of truth. Read it end-to-end before generating; do not infer the process from past commits or memory. The CI runs it under Copilot via `promote-rc.yaml::changelog` (and, as a backstop, `tags.yaml::generate-changelog`); locally the same prompt is replayable by following the doc directly.
|
||||
|
||||
### Common changelog failure modes
|
||||
|
||||
@@ -525,7 +536,7 @@ For RCs and final releases, run this before merging the release PR. Each item is
|
||||
- [ ] **For every CRD-source bump** (kubevirt, cert-manager, flux, cilium, kamaji): `helm template | kubectl apply --dry-run=server` against the freshly-installed CRDs.
|
||||
- [ ] **For every flux / helm-controller / kubevirt-operator / cert-manager bump:** paste the upstream breaking-changes section of the bump's CHANGELOG into the PR body before merge.
|
||||
- [ ] **After cutting the tag,** watch `pull-requests-release.yaml::Finalize Release`. If it fails on `Draft release for v... not found`, undraft the release manually and file the workflow regression — this has been a known recurring failure since the `Publish draft release` step lost its explicit `github-token` in refactor `66a756b6`.
|
||||
- [ ] **Changelog PR exists and was verified** per [Changelog pre-publish verification](#changelog-pre-publish-verification).
|
||||
- [ ] **The promote PR carries `docs/changelogs/vX.Y.Z.md` and it was verified** per [Changelog pre-publish verification](#changelog-pre-publish-verification). Finalize publishes this file as the release body, so verifying it before merge is verifying the release notes.
|
||||
|
||||
## CI failures release engineers commonly hit
|
||||
|
||||
@@ -550,7 +561,7 @@ Published tags are **write-once** — once a `vX.Y.Z` or rc tag is pushed it is
|
||||
|------|------------------------|
|
||||
| [`pull-requests-release.yaml`](../.github/workflows/pull-requests-release.yaml) | Creates `vX.Y.Z` at the PR merge commit **write-once** (create if absent, no-op if unchanged, fail loudly if it would move — the merge commit is new, so there is nothing to move). Cuts the `api/apps/v1alpha1/<vTAG>` Go-submodule tag write-once at the same commit, **stable only** (never rc/beta/alpha). The `release-X.Y` maintenance branch is fast-forward-only. Retags the rc image digests to the stable image tag (also write-once at the image level). |
|
||||
| [`tags.yaml`](../.github/workflows/tags.yaml) | The `release-X.Y.Z-rc.N` staging branch is a mutable staging ref (compare-before-force: skipped when unchanged, force+log only when it genuinely moves). Fails fast on a stable `vX.Y.Z` tag with no pre-existing draft — stable tags come from `promote-rc.yaml`, never a hand push. |
|
||||
| [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) | Mutates no tags. Stages the `release-X.Y.Z` branch and opens the promote PR; the stable tag and the image retag both happen at merge (finalize). |
|
||||
| [`promote-rc.yaml`](../.github/workflows/promote-rc.yaml) | Mutates no tags. Stages the `release-X.Y.Z` branch, generates the changelog onto it, and opens the promote PR; the stable tag and the image retag both happen at merge (finalize). The staging branch is force-pushed on re-dispatch, but an existing `docs/changelogs/vX.Y.Z.md` is preserved across the rebuild. |
|
||||
|
||||
This is the immutable-tag + rc-promotion model from [#2677](https://github.com/cozystack/cozystack/issues/2677): stable `vX.Y.Z` is created only by promoting an existing rc, rc tags are write-once, and `api/apps/v1alpha1/vX.Y.Z` is created only on a stable release. The old nightly `auto-release.yaml` (which delete-recreated auto-bumped patch tags) has been removed. rc and stable tags accrete permanently; the only churning artifacts are GHCR nightlies, which [`retention.yaml`](../.github/workflows/retention.yaml) prunes (see [Nightly builds](#nightly-builds)) — GHCR storage is the cost vector to watch.
|
||||
|
||||
@@ -576,7 +587,7 @@ Sometimes the work that has to land before a release is a 40-commit grab bag (CI
|
||||
|
||||
- The `release-X.Y.Z` branch is deleted by GitHub when its PR merges. The `release-X.Y` maintenance branch is created/updated by `pull-requests-release.yaml::Ensure maintenance branch`.
|
||||
- The draft release is published by the same workflow.
|
||||
- The `update-releasenotes.yaml` workflow syncs `docs/changelogs/vX.Y.Z.md` into the GitHub Release body on the next push to `main`. Edits to a published changelog file land on the release page the next time `main` moves.
|
||||
- The release body is set by finalize from `docs/changelogs/vX.Y.Z.md` at publication. Afterwards, `update-releasenotes.yaml` re-syncs that file into the GitHub Release body on any push to `main` that touches it — so edits to a published changelog land on the release page the next time `main` moves.
|
||||
- Local cleanup: remove your worktree (`git worktree remove`) and prune merged release branches.
|
||||
|
||||
## See also
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bats
|
||||
# Guards the contract that makes a release publish WITH its changelog.
|
||||
#
|
||||
# The release body is no longer written by hand or synced after the fact: it is
|
||||
# docs/changelogs/vX.Y.Z.md, generated by promote-rc.yaml, committed onto the
|
||||
# release-X.Y.Z staging branch, carried through the promote PR, and read back by
|
||||
# pull-requests-release.yaml finalize at publication. That chain spans three
|
||||
# workflow files and a docs contract, and every link is a plain string — a job
|
||||
# rename, a moved `if:`, or a reworded prompt breaks it silently, and the failure
|
||||
# only ever shows up as a published release with empty notes. That is exactly the
|
||||
# class of bug nobody notices until it is public and irreversible.
|
||||
#
|
||||
# The tests below pin the links that are cheap to assert and expensive to lose:
|
||||
#
|
||||
# 1. open-pr survives a failed changelog job (the non-blocking guarantee).
|
||||
# 2. The AI step is the ONLY thing allowed to fail silently.
|
||||
# 3. finalize reads the changelog file as the release body.
|
||||
# 4. finalize refuses whitespace-only content rather than blanking the notes.
|
||||
# 5. The generator is pointed at a checkout state docs/agents/changelog.md
|
||||
# actually permits.
|
||||
# 6. The tags.yaml backstop ports an existing changelog instead of regenerating.
|
||||
# 7. promote preserves a changelog across the staging-branch force-push.
|
||||
# 8. The finalize trigger has no paths filter that a docs-only promote PR would
|
||||
# trip over.
|
||||
#
|
||||
# WHAT THIS DOES NOT CHECK, so a green tick is not mistaken for a working release:
|
||||
#
|
||||
# * That the changelog is CORRECT, or that the AI produces anything at all.
|
||||
# Generation quality is unobservable from here and is what review is for.
|
||||
# * That GitHub's `needs` / `if:` semantics behave as assumed. These tests pin
|
||||
# what the YAML SAYS; whether a skipped upstream job really propagates is
|
||||
# GitHub's contract, verifiable only by dispatching a real promotion.
|
||||
# * Anything in cozystack/website. The docs job deliberately still runs at tag
|
||||
# push (where RELEASE_TAG resolves to a tag that exists) — moving it earlier
|
||||
# needs a change in that repo first, and nothing here would catch its absence.
|
||||
# * Whether the release body ACTUALLY lands. That is one API call at publish
|
||||
# time, on a path no unit test reaches.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
|
||||
PROMOTE="$REPO_ROOT/.github/workflows/promote-rc.yaml"
|
||||
FINALIZE="$REPO_ROOT/.github/workflows/pull-requests-release.yaml"
|
||||
TAGS="$REPO_ROOT/.github/workflows/tags.yaml"
|
||||
CHANGELOG_DOC="$REPO_ROOT/docs/agents/changelog.md"
|
||||
|
||||
# Body of a single top-level job block in a workflow file, from ` <name>:` up to
|
||||
# the next top-level job. Keyed on two-space indentation, which is the layout
|
||||
# every workflow in this repo uses.
|
||||
job_block() {
|
||||
awk -v job=" $1:" '
|
||||
$0 == job { inside = 1; next }
|
||||
/^ [a-z0-9_-]+:$/ { inside = 0 }
|
||||
inside' "$2"
|
||||
}
|
||||
|
||||
@test "all four release workflow files exist" {
|
||||
for f in "$PROMOTE" "$FINALIZE" "$TAGS" "$CHANGELOG_DOC"; do
|
||||
[ -f "$f" ] || { echo "missing $f" >&2; exit 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# 1. The non-blocking guarantee. `needs: [promote, changelog]` alone makes GitHub
|
||||
# SKIP open-pr whenever changelog fails, which would leave the staging branch
|
||||
# pushed and the release drafted but no promote PR at all — strictly worse than
|
||||
# the old flow, where the PR was opened inside promote and nothing about
|
||||
# changelog generation could block it. A Copilot or npm outage must degrade to
|
||||
# "PR opens without a changelog", never to "no PR".
|
||||
@test "open-pr opens the promote PR even when the changelog job fails" {
|
||||
block="$(job_block open-pr "$PROMOTE")"
|
||||
[ -n "$block" ] || { echo "Could not locate the open-pr job in $PROMOTE" >&2; exit 1; }
|
||||
|
||||
printf '%s\n' "$block" | grep -q 'needs: \[promote, changelog\]' || {
|
||||
echo "open-pr no longer declares 'needs: [promote, changelog]'." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Must not be gated on the changelog job's result.
|
||||
printf '%s\n' "$block" | grep -qE '^\s*if:.*!cancelled\(\)' || {
|
||||
echo "open-pr has no '!cancelled()' job-level if:. Without one, a failed" >&2
|
||||
echo "changelog job SKIPS open-pr and no promote PR is ever opened." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$block" | grep -qE "^\s*if:.*needs\.promote\.result == 'success'" || {
|
||||
echo "open-pr's if: no longer gates on needs.promote.result == 'success'." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$block" | grep -qE '^\s*if:.*needs\.changelog\.result' && {
|
||||
echo "open-pr's job-level if: references needs.changelog.result. Gating the" >&2
|
||||
echo "PR on the changelog job re-introduces the wedge this if: exists to prevent." >&2
|
||||
exit 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2. continue-on-error is a blast radius, not a convenience. It belongs on the AI
|
||||
# invocation and nowhere else: on a verification or commit step it would let a
|
||||
# broken changelog through as if it were fine.
|
||||
@test "only the AI generation step is allowed to fail silently" {
|
||||
block="$(job_block changelog "$PROMOTE")"
|
||||
[ -n "$block" ] || { echo "Could not locate the changelog job in $PROMOTE" >&2; exit 1; }
|
||||
|
||||
count="$(printf '%s\n' "$block" | grep -c 'continue-on-error: true' || true)"
|
||||
[ "$count" -eq 1 ] || {
|
||||
echo "Expected exactly 1 'continue-on-error: true' in the changelog job, found $count." >&2
|
||||
echo "Only the 'Generate changelog using AI' step may fail silently — on any other" >&2
|
||||
echo "step it would let an unvalidated or missing changelog through unnoticed." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The single occurrence must sit in the AI step, not somewhere else.
|
||||
ai_step="$(printf '%s\n' "$block" | awk '
|
||||
/^ - name: Generate changelog using AI$/ { inside = 1; next }
|
||||
/^ - name: / { inside = 0 }
|
||||
inside')"
|
||||
[ -n "$ai_step" ] || { echo "Could not locate the 'Generate changelog using AI' step" >&2; exit 1; }
|
||||
printf '%s\n' "$ai_step" | grep -q 'continue-on-error: true' || {
|
||||
echo "'Generate changelog using AI' is not the step carrying continue-on-error." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 3. The load-bearing read. If finalize stops reading the file, releases publish
|
||||
# with whatever the draft happened to carry ("Promoted from vX.Y.Z-rc.N.") and
|
||||
# the whole point of generating the changelog before publication is lost.
|
||||
@test "finalize uses the changelog file as the published release body" {
|
||||
grep -q 'docs/changelogs/\${tag}\.md' "$FINALIZE" || {
|
||||
echo "finalize no longer references docs/changelogs/\${tag}.md." >&2
|
||||
echo "The release body would fall back to the draft's placeholder text." >&2
|
||||
exit 1
|
||||
}
|
||||
# The read must actually reach updateRelease.
|
||||
grep -qE '\.\.\.\(body \? \{ body \} : \{\}\)' "$FINALIZE" || {
|
||||
echo "finalize's updateRelease call no longer conditionally spreads { body }." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 4. A whitespace-only file is worse than no file: it would blank the release
|
||||
# notes more thoroughly than publishing the draft body would. promote-rc
|
||||
# validates structurally, but a changelog can also arrive by hand-commit, so
|
||||
# finalize keeps its own last-line-of-defence check.
|
||||
@test "finalize refuses an empty or whitespace-only changelog as the body" {
|
||||
grep -qE 'raw\.trim\(\)\.length === 0' "$FINALIZE" || {
|
||||
echo "finalize no longer guards against a whitespace-only changelog." >&2
|
||||
echo "Such a file would publish as blank release notes." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 5. docs/agents/changelog.md section 2 is a hard gate: it enumerates the valid
|
||||
# working-tree states and tells the agent to STOP otherwise. The promotion job
|
||||
# checks out the rc tag, so that state must be one the doc names — otherwise a
|
||||
# COMPLIANT agent refuses to generate anything and the normal path produces no
|
||||
# changelog at all, silently, forever.
|
||||
@test "the promotion checkout state is one docs/agents/changelog.md permits" {
|
||||
block="$(job_block changelog "$PROMOTE")"
|
||||
printf '%s\n' "$block" | grep -qE 'ref: \$\{\{ steps\.parse\.outputs\.rc_tag \}\}' || {
|
||||
echo "The changelog job no longer checks out the rc TAG." >&2
|
||||
echo "A staging-branch checkout is a state changelog.md section 2 rejects." >&2
|
||||
exit 1
|
||||
}
|
||||
grep -qi 'release-candidate.* tag being promoted' "$CHANGELOG_DOC" || {
|
||||
echo "docs/agents/changelog.md section 2 no longer documents the promotion" >&2
|
||||
echo "configuration (detached at the rc tag). A compliant agent would stop." >&2
|
||||
exit 1
|
||||
}
|
||||
# The checkout must never consume raw dispatch input.
|
||||
printf '%s\n' "$block" | grep -qE 'ref: \$\{\{ inputs\.rc_tag \}\}' && {
|
||||
echo "The changelog job checks out raw inputs.rc_tag instead of the validated" >&2
|
||||
echo "parse output — untrusted input reaching actions/checkout's ref." >&2
|
||||
exit 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# 6. When a promote PR targets release-X.Y (a patch release), the changelog never
|
||||
# reaches main, so the backstop fires. It must PORT the reviewed file from the
|
||||
# tag, not regenerate: a second AI pass would spend quota and then, once its PR
|
||||
# merged, update-releasenotes.yaml would overwrite already-published release
|
||||
# notes with text nobody approved.
|
||||
@test "the tags.yaml backstop ports an existing changelog instead of regenerating" {
|
||||
block="$(job_block generate-changelog "$TAGS")"
|
||||
[ -n "$block" ] || { echo "Could not locate generate-changelog in $TAGS" >&2; exit 1; }
|
||||
|
||||
printf '%s\n' "$block" | grep -q 'at_tag=true' || {
|
||||
echo "The backstop no longer detects a changelog present at the tag commit." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Every AI step must be gated on the file being absent at the tag.
|
||||
for step in 'Setup Node.js' 'Install GitHub Copilot CLI' 'Generate changelog using AI'; do
|
||||
step_block="$(printf '%s\n' "$block" | awk -v s=" - name: $step" '
|
||||
$0 == s { inside = 1; next }
|
||||
/^ - name: / { inside = 0 }
|
||||
inside')"
|
||||
[ -n "$step_block" ] || { echo "Could not locate backstop step '$step'" >&2; exit 1; }
|
||||
printf '%s\n' "$step_block" | grep -qE "at_tag == 'false'" || {
|
||||
echo "Backstop step '$step' is not gated on at_tag == 'false'." >&2
|
||||
echo "It would regenerate a changelog that already exists at the tag, and the" >&2
|
||||
echo "resulting PR would overwrite published release notes." >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
# 7. The staging branch is rebuilt from the rc tree and force-pushed on every
|
||||
# re-dispatch. A changelog committed there by hand (the documented recovery
|
||||
# when generation fails) or by an earlier dispatch must survive that, or a
|
||||
# retry silently replaces human-edited release notes.
|
||||
@test "promote preserves an existing changelog across the staging-branch rebuild" {
|
||||
block="$(job_block promote "$PROMOTE")"
|
||||
[ -n "$block" ] || { echo "Could not locate the promote job in $PROMOTE" >&2; exit 1; }
|
||||
|
||||
printf '%s\n' "$block" | grep -q 'PRESERVED' || {
|
||||
echo "The Prepare stable branch step no longer preserves an existing changelog." >&2
|
||||
echo "A re-dispatch would force-push away a hand-written one." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$block" | grep -qE 'git show "FETCH_HEAD:\$\{CL\}"' || {
|
||||
echo "The preserve path no longer reads the changelog from the remote staging branch." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# And open-pr must not clobber what promote preserved.
|
||||
open_block="$(job_block open-pr "$PROMOTE")"
|
||||
printf '%s\n' "$open_block" | grep -qE "steps\.existing\.outputs\.present == 'false'" || {
|
||||
echo "open-pr no longer checks for an existing changelog before downloading the" >&2
|
||||
echo "generated artifact — it would overwrite preserved human edits." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 8. The promote PR now carries docs/changelogs/vX.Y.Z.md, and Prepare stable
|
||||
# branch tolerates producing no tag-string changes ("digests already stable").
|
||||
# Together those can yield a docs-only promote PR. A `paths-ignore: docs/**`
|
||||
# on the finalize trigger would drop it: no finalize run, so no tag, no
|
||||
# release, and no error raised anywhere.
|
||||
@test "the finalize trigger has no paths filter that a docs-only promote PR would trip" {
|
||||
trigger="$(awk '/^on:/ { inside = 1; next } /^[a-z]/ { inside = 0 } inside' "$FINALIZE")"
|
||||
[ -n "$trigger" ] || { echo "Could not locate the on: block in $FINALIZE" >&2; exit 1; }
|
||||
|
||||
printf '%s\n' "$trigger" | grep -qE '^\s*(paths|paths-ignore):' && {
|
||||
echo "The finalize trigger declares a paths filter. A promote PR whose only" >&2
|
||||
echo "change is the changelog would be filtered out, and the release would" >&2
|
||||
echo "never be tagged or published — silently. Filtering belongs in the job's" >&2
|
||||
echo "if: gate (merged + release label + bot author), not the trigger." >&2
|
||||
exit 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in new issue
Block a user