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@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 # Do NOT persist GITHUB_TOKEN as http.extraheader. Every tag/branch push # below re-injects the app token via `git remote set-url`, but a persisted # extraheader silently wins over the URL credential — so those pushes # would authenticate as GITHUB_TOKEN, and a GITHUB_TOKEN push creates no # workflow run (GitHub anti-recursion). That is exactly why v1.6.0's # stable tag triggered no tags.yaml run (the generate-changelog and # update-website-docs backstops never fired). With this false, the set-url # app token authenticates, matching how promote-rc.yaml already pushes. persist-credentials: false # Use the trusted pre-merge base version of the verifier, while its root # argument remains the merged packages tree checked out above. This keeps # the final guard independent of the release PR's executable contents. - name: Checkout release tooling from base uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.base.sha }} path: .release-tooling sparse-checkout: hack persist-credentials: false # Resolve and verify the candidate before creating any write-once stable # git or registry name. The candidate was serialized at promote time from # the tag-rewritten tree; this guard pulls it by the committed digest and # proves it still matches the merge commit (apart from its impossible # self-reference) and still pins the rc-tested container digests. # # flux and yq are version-pinned: they are what this job verifies the # candidate with and what stamps the stable chart, so `latest/download` # would make an irreversible step depend on whatever mikefarah shipped # this morning. The version tracks # packages/core/testing/images/e2e-sandbox/Dockerfile. yq is additionally # pinned by content — it stamps the chart's platformVersion, and this step # is the last one before write-once names exist. flux comes from the # commit-pinned upstream setup action, which checksums the release # tarball. promote-rc.yaml's copy carries the full reasoning. - name: Set up flux if: ${{ !contains(steps.get_tag.outputs.tag, '-') }} uses: fluxcd/flux2/action@04acaec6161ac4fb1a82ffafa88901c03271d34f # v2.8.6 with: version: "2.8.6" - name: Set up promotion toolchain (skopeo, yq, helm) if: ${{ !contains(steps.get_tag.outputs.tag, '-') }} env: YQ_VERSION: "4.53.3" YQ_SHA256: "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4" run: | if ! yq --version 2>/dev/null | grep -q "mikefarah.* version v${YQ_VERSION}\$"; then yq_bin="$(mktemp)" curl -fsSL -o "$yq_bin" \ "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64" printf '%s %s\n' "$YQ_SHA256" "$yq_bin" | sha256sum -c - sudo install -m 0755 "$yq_bin" /usr/local/bin/yq rm -f "$yq_bin" fi command -v skopeo >/dev/null \ || { sudo apt-get update -qq && sudo apt-get install -y -qq skopeo; } command -v helm >/dev/null \ || curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash # Not installed here, only required: `flux pull artifact` reads its # registry credentials from ~/.docker/config.json, which is why the # login step below runs `docker login` as well as skopeo's and helm's. # This job's runner class is not one of the docker-using build ones, so # say what is missing here rather than letting the login step fail with # a bare "command not found" in the middle of the release path. command -v docker >/dev/null \ || { echo "::error::docker is required on this runner: flux reads registry credentials from ~/.docker/config.json"; exit 1; } - name: Login to registry (GHCR) if: ${{ !contains(steps.get_tag.outputs.tag, '-') }} env: GHCR_USER: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "$GHCR_TOKEN" | docker login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin echo "$GHCR_TOKEN" | skopeo login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin echo "$GHCR_TOKEN" | helm registry login "${REGISTRY%%/*}" -u "$GHCR_USER" --password-stdin - name: Verify stable packages candidate if: ${{ !contains(steps.get_tag.outputs.tag, '-') }} env: TAG: ${{ steps.get_tag.outputs.tag }} run: .release-tooling/hack/verify-promoted-packages.sh "${TAG#v}" # Create the release tag at the merge commit — write-once. # # The tag is attached to the PR merge commit, which did not exist before # this release PR opened, so there is never a prior SHA to overwrite: a # force is impossible by construction. The guard makes that explicit and # makes re-runs idempotent. A pre-existing tag at a different commit means # something pushed the tag out-of-band — fail loudly rather than move it. - name: Create tag on merge commit (write-once) env: APP_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.get_tag.outputs.tag }} SHA: ${{ github.sha }} run: | git config user.name "cozystack-ci[bot]" git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} EXISTING="$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')" if [ -z "$EXISTING" ]; then git tag "${TAG}" "${SHA}" git push origin "refs/tags/${TAG}" echo "✅ Created ${TAG} at ${SHA}." elif [ "$EXISTING" = "${SHA}" ]; then echo "✅ ${TAG} already at ${SHA}; nothing to do." else echo "::error::${TAG} already exists at ${EXISTING}; refusing to force-move it to ${SHA} (tags are write-once)." exit 1 fi # Tag the api/apps/v1alpha1 submodule for pkg.go.dev — stable releases only. # # Moved here from tags.yaml: under the promote flow the stable draft already # exists when the stable tag is pushed, so tags.yaml's prepare-release body # (which used to cut this) is skipped and the Go-module tag was never # created for a promoted stable. Finalize is where the stable tag is born, # so it is where the matching submodule tag must be cut, at the same commit. # Write-once: api/apps/v1alpha1/* tags feed the Go module proxy and # pkg.go.dev, where a moved tag poisons caches irreversibly — create when # absent, no-op if it already points here, fail loudly if it points # elsewhere. Skipped for a prerelease tag (an rc/beta/alpha Go-module tag # would permanently pollute the proxy — #2677 goal 3); rc promotions never # open a release PR so never reach finalize, but the guard is kept explicit. - name: Tag API submodule (write-once) if: ${{ !contains(steps.get_tag.outputs.tag, '-') }} env: APP_TOKEN: ${{ steps.app-token.outputs.token }} VTAG: ${{ steps.get_tag.outputs.tag }} SHA: ${{ github.sha }} run: | SUBTAG="api/apps/v1alpha1/${VTAG}" git config user.name "cozystack-ci[bot]" git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} # The submodule tag points at the same commit as the stable tag created # above — github.sha, the merge commit. Resolve from SHA directly (not # from the vX.Y.Z tag) so an idempotent re-run, where the stable tag # already existed and no local tag was created, still finds the target. EXISTING="$(git ls-remote --tags origin "refs/tags/${SUBTAG}" | awk '{print $1}')" if [ -z "$EXISTING" ]; then git tag "${SUBTAG}" "${SHA}" git push origin "refs/tags/${SUBTAG}" echo "✅ Created ${SUBTAG} at ${SHA}." elif [ "$EXISTING" = "${SHA}" ]; then echo "✅ ${SUBTAG} already at ${SHA}; nothing to do." else echo "::error::${SUBTAG} already exists at ${EXISTING}; refusing to force-move it to ${SHA} (tags are write-once)." exit 1 fi # Ensure maintenance branch release-X.Y - name: Ensure maintenance branch release-X.Y uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const tag = '${{ steps.get_tag.outputs.tag }}'; // e.g. v0.1.3 or v0.1.3-rc3 const match = tag.match(/^v(\d+)\.(\d+)\.\d+(?:[-\w\.]+)?$/); if (!match) { core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-suffix'`); return; } const line = `${match[1]}.${match[2]}`; const branch = `release-${line}`; // Get main branch commit for the tag const ref = await github.rest.git.getRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `tags/${tag}` }); const commitSha = ref.data.object.sha; try { await github.rest.repos.getBranch({ owner: context.repo.owner, repo: context.repo.repo, branch }); // Fast-forward only — the maintenance branch is never rewound. // force:false makes GitHub reject (422) a non-fast-forward update; // we warn and leave the branch for a maintainer to resolve rather // than clobber history. try { await github.rest.git.updateRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branch}`, sha: commitSha, force: false }); console.log(`✅ Fast-forwarded '${branch}' to ${commitSha}`); } catch (ffErr) { // Only a non-fast-forward rejection (422) is the expected, // recoverable case. Auth/API/malformed-ref errors must not be // swallowed — they'd silently leave the maintenance branch stale. if (ffErr.status === 422) { console.log(`::warning::Could not fast-forward '${branch}' to ${commitSha} (not a descendant); leaving as-is — resolve manually. (${ffErr.message})`); } else { core.setFailed(`Unexpected error updating '${branch}': ${ffErr.message}`); throw ffErr; } } } catch (err) { if (err.status === 404) { await github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `refs/heads/${branch}`, sha: commitSha }); console.log(`✅ Created branch '${branch}' at ${commitSha}`); } else { console.error('Unexpected error --', err); core.setFailed(`Unexpected error creating/updating branch: ${err.message}`); throw err; } } # Publish draft release and ensure correct latest flag - name: Publish draft release id: publish uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const tag = '${{ steps.get_tag.outputs.tag }}'; const m = tag.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/); if (!m) { core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`); return; } const isRc = Boolean(m[2]); // Parse semver string to comparable numbers function parseSemver(v) { const match = v.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/); if (!match) return null; return { major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]) }; } // Compare two semver objects function compareSemver(a, b) { if (a.major !== b.major) return a.major - b.major; if (a.minor !== b.minor) return a.minor - b.minor; return a.patch - b.patch; } const currentSemver = parseSemver(tag); // Get all releases const releases = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }); // Find draft release to publish. // // Normally the draft was created by tags.yaml with tag_name === tag. // But if the git tag was deleted and re-created between draft creation // and merge (e.g. the release branch was force-updated), GitHub orphans // the draft: tag_name becomes "untagged-" 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: consumer-visible registry side effects run # HERE, after the candidate guard passed and the promote PR merged. # promote-rc.yaml published only a temporary `promotion-*` packages # candidate, so an abandoned promotion leaves 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 and the verified # candidate pinned. skopeo retags those digests to the stable image tag; # helm packages+pushes the stable chart. # Retag every rc image/digest plus the verified packages candidate to the # stable tag. No container 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 container 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