name: Release on: push: tags: - 'v*.*.*' permissions: contents: read env: OCI_REGISTRY_HOST: ${{ vars.OCI_REGISTRY_HOST || 'ghcr.io' }} IMAGE_NAME: ${{ vars.IMAGE_NAME || 'openchoreo/openchoreo-ui' }} jobs: # Build and publish are deliberately separate jobs. # # `yarn install` executes lifecycle scripts from the full transitive # dependency tree (this repo needs them — see enableScripts in .yarnrc.yml). # If that ran in the job holding `id-token: write`, any compromised # dependency could mint an npm publish credential from the OIDC endpoint and # ship arbitrary code as @openchoreo. The `npm-publish` environment approval # does not help: it gates job start, so scripts still run after approval. # # So: `build` installs and compiles with no OIDC access and emits tarballs; # `publish-npm` holds the OIDC token but never installs dependencies. build: name: Build packages runs-on: ubuntu-24.04 permissions: contents: read outputs: release-tag: ${{ steps.release-vars.outputs.release-tag }} git-sha-short: ${{ steps.release-vars.outputs.git-sha-short }} is-prerelease: ${{ steps.release-vars.outputs.is-prerelease }} latest: ${{ steps.release-vars.outputs.latest }} dist-tag: ${{ steps.release-vars.outputs.dist-tag }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Resolve release variables id: release-vars run: | git fetch --tags --force GIT_SHA_SHORT=$(git rev-parse --short=8 HEAD) RELEASE_TAG=${GITHUB_REF##*/} # Detect prerelease versions (SemVer tags containing a hyphen, e.g., v1.0.0-rc.1) if [[ "${RELEASE_TAG}" == *-* ]]; then IS_PRERELEASE=true else IS_PRERELEASE=false fi latest_tag=$(git tag -l | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1) if [ "$latest_tag" == "${RELEASE_TAG}" ]; then LATEST=true else LATEST=false fi # Dist-tag scheme: `latest` only for the highest stable version, # `next` for prereleases, `release-X.Y` for back-line patches so a # re-release of an older line never steals `latest` from a newer one. DIST_TAG=latest if [ "${IS_PRERELEASE}" = "true" ]; then DIST_TAG=next elif [ "${LATEST}" != "true" ]; then VERSION=${RELEASE_TAG#v} DIST_TAG="release-${VERSION%.*}" fi { echo "release-tag=${RELEASE_TAG}" echo "git-sha-short=${GIT_SHA_SHORT}" echo "is-prerelease=${IS_PRERELEASE}" echo "latest=${LATEST}" echo "dist-tag=${DIST_TAG}" } >> $GITHUB_OUTPUT - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'yarn' - name: Install dependencies run: yarn install --immutable - name: TypeScript compile # Backstage's per-package builder reads `.d.ts` files from # `dist-types/` during `build:all`. Without a prior `yarn tsc` the # build fails with "No declaration files found at ../../dist-types/...". # Mirrors the order used by build-and-test.yml. run: yarn tsc - name: Build all workspaces run: yarn build:all - name: Record topological publish order # Dependencies must be published before dependents so a consumer # installing mid-release never sees a package whose dependency is not # on the registry yet. `yarn pack` loses that ordering, so capture it # while the workspace graph is still available. run: | : > publish-order.txt yarn workspaces foreach --all --no-private --topological exec \ bash -c 'printf "%s\n" "${PWD#"$GITHUB_WORKSPACE"/}" >> "$GITHUB_WORKSPACE/publish-order.txt"' echo "Publish order:" cat publish-order.txt - name: Pack workspaces # `yarn pack` runs each workspace's `prepack` and rewrites `workspace:^` # deps to concrete versions. `npm pack` does not, and would leak # `workspace:^` strings into the tarballs and break installs for # external consumers. Packing here (not publishing) is what lets the # publish job skip `yarn install` entirely. run: yarn workspaces foreach --all --no-private --topological --verbose pack - name: 'Verify no workspace: leaks in tarballs' # A `workspace:` specifier reaching the registry is unrecoverable # without a version bump, so fail the release before anything ships. run: | status=0 while read -r loc; do leaks=$(tar -xzOf "${loc}/package.tgz" package/package.json | node -e ' let s = ""; process.stdin.on("data", d => (s += d)).on("end", () => { const m = JSON.parse(s); const bad = []; for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) { for (const [k, v] of Object.entries(m[field] || {})) { if (String(v).startsWith("workspace:")) bad.push(`${field}.${k}=${v}`); } } if (bad.length) console.log(bad.join(" ")); }); ') if [ -n "$leaks" ]; then echo "::error::${loc} leaks workspace: specifiers -> ${leaks}" status=1 fi done < publish-order.txt [ "$status" -eq 0 ] && echo "No workspace: leaks found." exit $status - name: Upload package tarballs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: npm-tarballs path: | publish-order.txt packages/*/package.tgz plugins/*/package.tgz retention-days: 7 if-no-files-found: error publish-npm: name: Publish to npm needs: build runs-on: ubuntu-24.04 # Bound to a protected environment with required reviewers. npm matches the # OIDC claim against repository + workflow filename + THIS environment name, # so renaming it here also requires updating every package's trusted # publisher (`npm trust github @openchoreo/ --environment ...`). environment: npm-publish permissions: # Deliberately minimal. No `contents: read` — this job does not check out # the repository; it only consumes the tarballs built above. id-token: write steps: - name: Download package tarballs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: npm-tarballs - name: Setup Node.js # Node 24 (LTS) bundles npm 11.19.0. Node 22 bundles npm 10.x, which # predates OIDC support — on npm < 11.5.1 the registry treats the # publish as anonymous and returns a misleading 404 rather than an auth # error. Using a Node line that already ships a capable npm avoids # installing any registry-resolved tooling inside this job: nothing # executable is fetched from the npm registry while the OIDC token is # reachable. Node itself comes from nodejs.org via setup-node. uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 - name: Verify npm supports trusted publishing # Fail loudly here rather than as a confusing 404 at publish time if a # future Node 24.x ever ships an npm below the OIDC floor. run: | MIN=11.5.1 NPM_VERSION=$(npm --version) echo "npm ${NPM_VERSION} (minimum ${MIN})" if [ "$(printf '%s\n' "$MIN" "$NPM_VERSION" | sort -V | head -n1)" != "$MIN" ]; then echo "::error::npm ${NPM_VERSION} is older than ${MIN}; trusted publishing would fail as a 404." exit 1 fi - name: Publish to npm # Auth is npm trusted publishing (OIDC) — there is deliberately no # NPM_TOKEN here. npm exchanges the workflow's id-token for a # short-lived registry credential and attaches a provenance attestation. # # No `yarn install` runs in this job, so no dependency lifecycle script # ever executes while the OIDC endpoint is reachable. env: DIST_TAG: ${{ needs.build.outputs.dist-tag }} run: | published=0 skipped=0 while read -r loc; do tgz="${loc}/package.tgz" spec=$(tar -xzOf "${tgz}" package/package.json | node -e ' let s = ""; process.stdin.on("data", d => (s += d)).on("end", () => { const m = JSON.parse(s); process.stdout.write(`${m.name}@${m.version}`); }); ') # Replaces yarn's --tolerate-republish: re-running a tag after a # partial failure must skip what already landed rather than abort. if npm view "${spec}" version --registry https://registry.npmjs.org >/dev/null 2>&1; then echo "skip ${spec} (already on registry)" skipped=$((skipped + 1)) continue fi echo "publish ${spec} --tag ${DIST_TAG}" npm publish "${tgz}" \ --access public \ --tag "${DIST_TAG}" \ --provenance \ --registry https://registry.npmjs.org published=$((published + 1)) done < publish-order.txt echo "Published ${published}, skipped ${skipped}." publish-template: name: Verify scaffold and publish template needs: [build, publish-npm] runs-on: ubuntu-24.04 permissions: contents: read env: RELEASE_TAG: ${{ needs.build.outputs.release-tag }} IS_PRERELEASE: ${{ needs.build.outputs.is-prerelease }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'yarn' - name: Install dependencies # This job runs the create-portal CLI from the repo checkout, which # needs the workspace's node_modules. It holds no OIDC token (npm # publishing already finished in `publish-npm`), so dependency # lifecycle scripts here cannot reach a publish credential. run: yarn install --immutable - name: Render the portal template # templates/ is gitignored (rendered at pack time, not committed), so # a fresh checkout must render it before the CLI can run. run: yarn workspace @openchoreo/create-portal generate-template # Runs AFTER publish (the scaffold's pinned ^X.Y.Z versions must exist # on the registry) and BEFORE the template push / Docker retag, so a # broken scaffold aborts the release before anything user-visible moves. - name: Smoke-test the portal scaffold run: | node packages/create-portal/bin/create-portal \ --name openchoreo-portal --path /tmp/verify-portal --skip-install cd /tmp/verify-portal yarn install --no-immutable yarn tsc - name: Push template to openchoreo/portal-template env: TEMPLATE_PUSH_TOKEN: ${{ secrets.PORTAL_TEMPLATE_PUSH_TOKEN }} run: | if [ -z "${TEMPLATE_PUSH_TOKEN}" ]; then echo "::warning::PORTAL_TEMPLATE_PUSH_TOKEN not set — skipping the portal-template publish" exit 0 fi node packages/create-portal/bin/create-portal \ --name openchoreo-portal --path /tmp/portal-template-render --skip-install rm -rf /tmp/portal-template-render/.git # A missing repo or a token without access must not fail the # release: npm packages are already published by this point. if ! git clone "https://x-access-token:${TEMPLATE_PUSH_TOKEN}@github.com/openchoreo/portal-template.git" /tmp/portal-template-repo; then echo "::warning::Could not clone openchoreo/portal-template — skipping the portal-template publish" exit 0 fi # Release retries re-run this step; the tag from the first attempt # already exists, so skip instead of failing the whole release. if git -C /tmp/portal-template-repo rev-parse -q --verify "refs/tags/${RELEASE_TAG}" > /dev/null; then echo "::notice::portal-template already has ${RELEASE_TAG} — skipping the template publish" exit 0 fi rsync -a --delete --exclude .git /tmp/portal-template-render/ /tmp/portal-template-repo/ cd /tmp/portal-template-repo git add -A git -c user.name=openchoreo-bot -c user.email=bot@openchoreo.dev \ commit -m "chore: portal template for ${RELEASE_TAG}" --allow-empty git tag "${RELEASE_TAG}" # Stable releases advance main; prereleases advance a force-pushed # `next` branch so main only ever contains released templates. if [ "${IS_PRERELEASE}" = "true" ]; then git push --force origin HEAD:next "${RELEASE_TAG}" else git push origin HEAD:main "${RELEASE_TAG}" fi retag-image: name: Retag release image needs: [build, publish-npm, publish-template] runs-on: ubuntu-24.04 permissions: packages: write env: RELEASE_TAG: ${{ needs.build.outputs.release-tag }} GIT_SHA_SHORT: ${{ needs.build.outputs.git-sha-short }} IS_PRERELEASE: ${{ needs.build.outputs.is-prerelease }} LATEST: ${{ needs.build.outputs.latest }} steps: - name: Login to container registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ${{ env.OCI_REGISTRY_HOST }} username: ${{ env.OCI_REGISTRY_HOST == 'ghcr.io' && (secrets.OCI_REGISTRY_USERNAME || github.actor) || secrets.OCI_REGISTRY_USERNAME }} password: ${{ env.OCI_REGISTRY_HOST == 'ghcr.io' && (secrets.OCI_REGISTRY_PASSWORD || github.token) || secrets.OCI_REGISTRY_PASSWORD }} - name: Retag existing image with release version run: | IMAGE="$OCI_REGISTRY_HOST/$IMAGE_NAME" if ! docker buildx imagetools inspect "${IMAGE}:${GIT_SHA_SHORT}" > /dev/null 2>&1; then echo "::error::Image ${IMAGE}:${GIT_SHA_SHORT} not found. Ensure the commit was pushed to a release branch before tagging." exit 1 fi echo "Retagging ${IMAGE}:${GIT_SHA_SHORT} -> ${IMAGE}:${RELEASE_TAG}" docker buildx imagetools create \ --tag "${IMAGE}:${RELEASE_TAG}" \ "${IMAGE}:${GIT_SHA_SHORT}" - name: Tag latest if highest stable version if: env.LATEST == 'true' && env.IS_PRERELEASE == 'false' run: | IMAGE="$OCI_REGISTRY_HOST/$IMAGE_NAME" echo "Retagging ${IMAGE}:${RELEASE_TAG} -> ${IMAGE}:latest" docker buildx imagetools create \ --tag "${IMAGE}:latest" \ "${IMAGE}:${RELEASE_TAG}"