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-" 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