From a265bf2967a17646be07984ece3184e97ed9310f Mon Sep 17 00:00:00 2001 From: Arvin Rolos Date: Mon, 13 Jul 2026 15:07:30 -0700 Subject: [PATCH 1/3] Update GitHub Actions dependencies to latest versions --- .github/workflows/cdn-upload.yaml | 4 ++-- .github/workflows/oci-build.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cdn-upload.yaml b/.github/workflows/cdn-upload.yaml index 9bc6834..dab1463 100644 --- a/.github/workflows/cdn-upload.yaml +++ b/.github/workflows/cdn-upload.yaml @@ -47,13 +47,13 @@ jobs: steps: - name: Download build artifact if: inputs.artifact_name != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ inputs.artifact_name }} path: ${{ inputs.dist_dir }} - name: Upload assets to Azure Blob Storage - uses: azure/CLI@v2 + uses: azure/cli@v3 env: SAS_TOKEN: ${{ secrets.CDN_SAS_TOKEN }} CDN_ACCOUNT_NAME: ${{ inputs.cdn_account_name }} diff --git a/.github/workflows/oci-build.yaml b/.github/workflows/oci-build.yaml index aa4ceb9..5971485 100644 --- a/.github/workflows/oci-build.yaml +++ b/.github/workflows/oci-build.yaml @@ -104,7 +104,7 @@ jobs: actions: read # Required by codeql-action/upload-sarif on private repos steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -245,7 +245,7 @@ jobs: ${{ env.REGISTRY }}/${{ github.repository }}/${{ inputs.image_name }}:${{ steps.version.outputs.next }}-rc.${{ steps.version.outputs.ts }}.${{ steps.version.outputs.short }} # ── Sign with Cosign (keyless / Sigstore) ──────────────── - - uses: sigstore/cosign-installer@v3 + - uses: sigstore/cosign-installer@v4.1.2 if: steps.changes.outputs.changed == 'true' && github.event_name != 'pull_request' - name: Sign image From ced5ffcfcdb5d1e62787aac3a93f49801db6e40a Mon Sep 17 00:00:00 2001 From: Arvin Rolos Date: Tue, 14 Jul 2026 10:07:20 -0700 Subject: [PATCH 2/3] remove force node24 --- .github/workflows/oci-build.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/oci-build.yaml b/.github/workflows/oci-build.yaml index 5971485..a6eedf8 100644 --- a/.github/workflows/oci-build.yaml +++ b/.github/workflows/oci-build.yaml @@ -85,7 +85,6 @@ on: env: REGISTRY: ghcr.io - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build: From 37f0e1320f2d273db7f18ced511e33b801f4d27e Mon Sep 17 00:00:00 2001 From: Arvin Rolos Date: Wed, 15 Jul 2026 11:49:36 -0700 Subject: [PATCH 3/3] Add reusable workflows for Helm chart linting, publishing, PR title validation, and release automation --- .github/workflows/helm-lint.yaml | 121 ++++++++++++++ .github/workflows/helm-publish.yaml | 220 ++++++++++++++++++++++++++ .github/workflows/pr-title-lint.yaml | 121 ++++++++++++++ .github/workflows/release-please.yaml | 176 +++++++++++++++++++++ .github/workflows/release-retag.yaml | 208 ++++++++++++++++++++++++ 5 files changed, 846 insertions(+) create mode 100644 .github/workflows/helm-lint.yaml create mode 100644 .github/workflows/helm-publish.yaml create mode 100644 .github/workflows/pr-title-lint.yaml create mode 100644 .github/workflows/release-please.yaml create mode 100644 .github/workflows/release-retag.yaml diff --git a/.github/workflows/helm-lint.yaml b/.github/workflows/helm-lint.yaml new file mode 100644 index 0000000..5700d90 --- /dev/null +++ b/.github/workflows/helm-lint.yaml @@ -0,0 +1,121 @@ +# Reusable workflow: lint and (optionally) kubeconform-validate Helm charts. +# +# Language-agnostic — works for single-chart repos (chart_glob: "charts/") +# and monorepos with several charts (chart_glob: "charts/*/"). +# +# Single-chart caller (sugar-suite / course-workload-estimator): +# +# jobs: +# helm-lint: +# uses: bcit-tlu/.github/.github/workflows/helm-lint.yaml@main +# with: +# chart_glob: "charts/" +# extra_command: bash tests/helm-cdn-rewrite.sh # optional +# +# Monorepo caller (hriv): +# +# jobs: +# helm-lint: +# uses: bcit-tlu/.github/.github/workflows/helm-lint.yaml@main +# with: +# chart_glob: "charts/*/" +# validate_continue_on_error: true +# extra_command: bash scripts/test-helm-chart-regressions.sh + +name: Helm lint + +on: + workflow_call: + inputs: + chart_glob: + description: "Space/glob-expandable list of chart directories to lint (e.g. 'charts/' or 'charts/*/')." + required: false + type: string + default: "charts/" + kubeconform_version: + description: "kubeconform release to install." + required: false + type: string + default: v0.6.7 + validate_continue_on_error: + description: "Treat kubeconform validation failures as non-fatal (schema downloads can be flaky). Strict `helm lint` stays the hard gate." + required: false + type: boolean + default: false + extra_command: + description: "Optional shell command run after validation (e.g. a repo-specific chart test script)." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # Bring the chart sources into the runner. + - uses: actions/checkout@v7 + + # `helm` is used both to lint and to render charts for kubeconform. + - uses: azure/setup-helm@v5 + + # kubeconform renders + schema-validates the templated manifests. + - name: Install kubeconform + env: + KUBECONFORM_VERSION: ${{ inputs.kubeconform_version }} + run: | + curl -sSL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" \ + | sudo tar xz -C /usr/local/bin kubeconform + + # Cache the downloaded JSON schemas so repeated runs don't re-fetch them + # (keyed on the kubeconform version + Chart.yaml set). + - name: Cache kubeconform schemas + uses: actions/cache@v5 + with: + path: /tmp/kubeconform-cache + key: kubeconform-schemas-${{ inputs.kubeconform_version }}-${{ hashFiles('charts/**/Chart.yaml') }} + restore-keys: | + kubeconform-schemas-${{ inputs.kubeconform_version }}- + + # Hard gate: `helm lint` each chart in the glob. Any failure fails CI. + - name: Lint charts (strict gate) + env: + CHART_GLOB: ${{ inputs.chart_glob }} + run: | + set -euo pipefail + for chart in ${CHART_GLOB}; do + echo "::group::helm lint ${chart}" + helm lint "${chart}" + echo "::endgroup::" + done + + # kubeconform schema downloads from raw.githubusercontent.com can be flaky + # on shared runners. Callers that want the strict `helm lint` above to be + # the primary gate can set validate_continue_on_error to absorb transient + # download failures here. + - name: Validate rendered charts with kubeconform + continue-on-error: ${{ inputs.validate_continue_on_error }} + env: + CHART_GLOB: ${{ inputs.chart_glob }} + run: | + set -euo pipefail + mkdir -p /tmp/kubeconform-cache + for chart in ${CHART_GLOB}; do + echo "::group::kubeconform ${chart}" + helm template test "${chart}" \ + | kubeconform -cache /tmp/kubeconform-cache -strict -summary \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ + -ignore-missing-schemas + echo "::endgroup::" + done + + # Optional repo-specific check (e.g. a chart regression/CDN-rewrite + # script) run only when the caller supplies `extra_command`. + - name: Run extra command + if: inputs.extra_command != '' + run: ${{ inputs.extra_command }} diff --git a/.github/workflows/helm-publish.yaml b/.github/workflows/helm-publish.yaml new file mode 100644 index 0000000..1f4af45 --- /dev/null +++ b/.github/workflows/helm-publish.yaml @@ -0,0 +1,220 @@ +# Reusable workflow: package, push, and cosign-sign a Helm chart to an OCI +# registry. +# +# Two version modes: +# * RC publish (from ci.yaml): pass `version` directly (e.g. the +# oci-build rc_version). Empty `version` = nothing to publish → skip. +# * Release publish (from a `release: published` workflow): pass +# `tag_name` + `tag_prefix`; the tag is parsed to a semver `version`. +# A tag that doesn't match the prefix is skipped (useful for monorepo +# matrices where only one component's tag matches). +# +# RC caller (single chart, sugar-suite ci.yaml): +# +# helm-publish: +# needs: [helm-lint, build-sugar-suite, cdn-upload] +# if: github.event_name != 'pull_request' && needs.build-sugar-suite.outputs.rc_version != '' +# uses: bcit-tlu/.github/.github/workflows/helm-publish.yaml@main +# permissions: +# contents: read +# packages: write +# id-token: write +# with: +# chart_dir: charts +# version: ${{ needs.build-sugar-suite.outputs.rc_version }} +# inject_cdn_sha: true +# secrets: inherit +# +# Release caller (monorepo matrix, hriv helm-publish.yaml): +# +# publish: +# strategy: +# matrix: +# component: [frontend, backend, backup] +# uses: bcit-tlu/.github/.github/workflows/helm-publish.yaml@main +# permissions: +# contents: read +# packages: write +# id-token: write +# with: +# chart_dir: charts/${{ matrix.component }} +# tag_name: ${{ inputs.tag_name || github.event.release.tag_name }} +# tag_prefix: ${{ matrix.component }}-v +# ref: ${{ inputs.tag_name || github.event.release.tag_name }} +# update_dependencies: true +# secrets: inherit + +name: Helm publish + +on: + workflow_call: + inputs: + chart_dir: + description: "Path to the chart directory (e.g. 'charts' or 'charts/frontend')." + required: true + type: string + version: + description: "Explicit chart/app version to publish. When set, tag_name/tag_prefix are ignored." + required: false + type: string + default: "" + tag_name: + description: "Release tag to parse when `version` is empty (e.g. v1.2.3 or backend-v1.2.3)." + required: false + type: string + default: "" + tag_prefix: + description: "Prefix preceding the semver in tag_name (e.g. 'v' or 'backend-v'). A tag not matching is skipped." + required: false + type: string + default: v + ref: + description: "Git ref to checkout (defaults to the triggering commit). Set to the release tag for release publishes." + required: false + type: string + default: "" + inject_cdn_sha: + description: "Inject the short commit SHA into /values.yaml `cdn.commitSha` before packaging." + required: false + type: boolean + default: false + update_dependencies: + description: "Run `helm dependency update` before packaging (charts with subchart deps)." + required: false + type: boolean + default: false + registry: + description: "OCI registry hosting the chart." + required: false + type: string + default: ghcr.io + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + env: + REGISTRY: ${{ inputs.registry }} + steps: + # Decide what version to publish (and whether to publish at all): + # * explicit `version` wins (RC publishes from ci.yaml); + # * otherwise parse the semver out of `tag_name` using `tag_prefix`; + # * a non-matching tag sets publish=false so the rest of the job is + # skipped (lets monorepo matrices no-op on other components' tags). + - name: Resolve version + id: resolve + shell: bash + env: + VERSION_IN: ${{ inputs.version }} + TAG: ${{ inputs.tag_name }} + PREFIX: ${{ inputs.tag_prefix }} + run: | + set -euo pipefail + if [[ -n "${VERSION_IN}" ]]; then + echo "version=${VERSION_IN}" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${TAG}" =~ ^${PREFIX}([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "No version input and tag '${TAG}' does not match prefix '${PREFIX}'; skipping." + fi + + # Check out the exact ref being published (release tag for releases, + # triggering commit for RC publishes) so the packaged chart matches it. + - uses: actions/checkout@v7 + if: steps.resolve.outputs.publish == 'true' + with: + ref: ${{ inputs.ref || github.sha }} + + # `helm` packages/pushes the chart; cosign signs the pushed artifact. + - uses: azure/setup-helm@v5 + if: steps.resolve.outputs.publish == 'true' + + - uses: sigstore/cosign-installer@v4.1.2 + if: steps.resolve.outputs.publish == 'true' + + # cosign + helm each need their own OCI login to the registry. + - name: Cosign login (OCI) + if: steps.resolve.outputs.publish == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} + run: | + echo "${GITHUB_TOKEN}" | \ + cosign login "${REGISTRY}" \ + -u "${ACTOR}" \ + --password-stdin + + # `helm registry login` is separate from the cosign login above. + - name: Helm login (OCI) + if: steps.resolve.outputs.publish == 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} + run: | + echo "${GITHUB_TOKEN}" | \ + helm registry login "${REGISTRY}" \ + -u "${ACTOR}" \ + --password-stdin + + # Package the chart at the resolved version, push it to the OCI registry, + # then keyless-sign the pushed digest with cosign. + - name: Package, push & sign chart + if: steps.resolve.outputs.publish == 'true' + shell: bash + env: + VERSION: ${{ steps.resolve.outputs.version }} + CHART_DIR: ${{ inputs.chart_dir }} + INJECT_CDN_SHA: ${{ inputs.inject_cdn_sha }} + UPDATE_DEPS: ${{ inputs.update_dependencies }} + OCI_BASE: oci://${{ env.REGISTRY }}/${{ github.repository }}/charts + IMAGE_BASE: ${{ env.REGISTRY }}/${{ github.repository }}/charts + run: | + set -euo pipefail + + CHART_NAME=$(yq '.name' "${CHART_DIR}/Chart.yaml") + DEST_DIR="/tmp/charts" + mkdir -p "${DEST_DIR}" + + # Inject short commit SHA for CDN immutable path (matched by upload prefix). + if [[ "${INJECT_CDN_SHA}" == "true" ]]; then + SHORT_SHA="$(git rev-parse --short HEAD)" + yq -i ".cdn.commitSha = \"${SHORT_SHA}\"" "${CHART_DIR}/values.yaml" + fi + + if [[ "${UPDATE_DEPS}" == "true" ]]; then + helm dependency update "${CHART_DIR}" || true + fi + + echo "::group::${CHART_NAME} ${VERSION}" + helm package "${CHART_DIR}" \ + --version "${VERSION}" \ + --app-version "${VERSION}" \ + -d "${DEST_DIR}" + + PUSH_OUT=$(helm push \ + "${DEST_DIR}/${CHART_NAME}-${VERSION}.tgz" \ + "${OCI_BASE}" 2>&1) || { echo "${PUSH_OUT}"; exit 1; } + echo "${PUSH_OUT}" + + # Tolerant digest parse: case-insensitive, allows leading whitespace. + DIGEST=$(printf '%s\n' "${PUSH_OUT}" \ + | awk 'tolower($1)=="digest:"{print $2; exit}') + if [[ -z "${DIGEST}" ]]; then + echo "::error::helm push for ${CHART_NAME} succeeded but no digest found in output (helm output format may have changed)" + exit 1 + fi + + cosign sign --yes "${IMAGE_BASE}/${CHART_NAME}@${DIGEST}" + echo "::endgroup::" diff --git a/.github/workflows/pr-title-lint.yaml b/.github/workflows/pr-title-lint.yaml new file mode 100644 index 0000000..16a1c93 --- /dev/null +++ b/.github/workflows/pr-title-lint.yaml @@ -0,0 +1,121 @@ +# Reusable workflow: enforce Conventional Commit PR titles. +# +# Language-agnostic — works for any repo (python, node, helm, etc.) +# whose release tooling parses PR titles (e.g. release-please). +# +# Caller example: +# +# name: pr-title-lint +# on: +# pull_request_target: +# types: [opened, edited, reopened, synchronize] +# branches: [main] +# permissions: +# contents: read +# pull-requests: write +# jobs: +# lint: +# uses: bcit-tlu/.github/.github/workflows/pr-title-lint.yaml@main +# permissions: +# contents: read +# pull-requests: write +# secrets: inherit + +name: PR title lint + +on: + workflow_call: + inputs: + types: + description: "Newline-separated Conventional Commit types to allow." + required: false + type: string + default: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + require_scope: + description: "Require a scope in the PR title (e.g. feat(api): ...)." + required: false + type: boolean + default: false + subject_pattern: + description: "Regex the subject must match." + required: false + type: string + default: "^(?![A-Z]).+$" + subject_pattern_error: + description: "Message shown when the subject fails subject_pattern." + required: false + type: string + default: | + The subject "{subject}" in PR title "{title}" must start with a + lowercase letter (imperative mood, no trailing period). See + CONTRIBUTING.md for examples. + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + if: github.event.pull_request.user.type != 'Bot' + permissions: + contents: read + pull-requests: write + steps: + # Validate the PR title against the allowed Conventional Commit types + # and subject pattern. Fails the job on a non-conforming title. + - id: lint + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: ${{ inputs.types }} + requireScope: ${{ inputs.require_scope }} + subjectPattern: ${{ inputs.subject_pattern }} + subjectPatternError: ${{ inputs.subject_pattern_error }} + + # On failure, post/update a sticky comment explaining the required + # format so contributors can fix the title without digging through docs. + - name: Post failure explainer comment + if: failure() && steps.lint.outcome == 'failure' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-title-lint + message: | + ## PR title must follow Conventional Commits + + release-please uses PR titles on `main` to determine version bumps and changelog entries. + + ``` + [optional scope][!]: + ``` + + - `type`: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` + - `subject`: start lowercase, imperative mood, no trailing period + - breaking change: add `!` (or `BREAKING CHANGE:` in body) + + Examples: + - ✅ `feat: add search page` + - ✅ `fix: correct broken nav link` + - ❌ `Add search page` + - ❌ `feat: Add search page` + + Edit the PR title in GitHub; this check reruns automatically. + + # Once the title passes, remove the sticky failure comment. + - name: Clear failure explainer on success + if: success() && steps.lint.outcome == 'success' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-title-lint + delete: true diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 0000000..1257504 --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,176 @@ +# Reusable workflow: run release-please, guard against stale `release-as` +# pins, and (for the common single-tag case) dispatch downstream tag +# workflows. Releases created with GITHUB_TOKEN don't trigger `on: +# release` workflows (anti-recursion guard), so they must be dispatched. +# +# Language-agnostic — release-please supports many release types +# (simple, node, python, etc.); the repo's own release-please-config.json +# selects the behavior. This workflow just orchestrates the run. +# +# Single-component caller (sugar-suite) — let the shared workflow do the +# dispatch via `dispatch_workflows` (no local job needed): +# +# jobs: +# release: +# uses: bcit-tlu/.github/.github/workflows/release-please.yaml@main +# permissions: +# contents: write +# issues: write +# pull-requests: write +# actions: write # for dispatch_workflows +# with: +# dispatch_workflows: | +# helm-publish.yaml +# release-retag.yaml +# secrets: inherit +# +# Monorepo caller (hriv) — a single run can release multiple components, +# so skip `dispatch_workflows` and dispatch per released path in a local +# job (using the exposed paths_released / outputs). Pass a GitHub App +# token so the release PR push triggers downstream CI (GITHUB_TOKEN's +# anti-recursion guard would suppress it): +# +# jobs: +# release: +# uses: bcit-tlu/.github/.github/workflows/release-please.yaml@main +# permissions: +# contents: write +# issues: write +# pull-requests: write +# with: +# app_id: ${{ vars.RELEASE_PLEASE_APP_ID }} +# secrets: +# app_private_key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} +# dispatch: +# needs: release +# ... # loop over needs.release.outputs.paths_released / outputs + +name: Release please + +on: + workflow_call: + inputs: + guard_stale_release_as: + description: "Fail if a `release-as` pin already matches the manifest (stale)." + required: false + type: boolean + default: true + app_id: + description: "GitHub App ID for a token that triggers downstream CI on the release PR push. Empty = use GITHUB_TOKEN." + required: false + type: string + default: "" + dispatch_workflows: + description: "Newline-separated workflow filenames to dispatch (with -f tag_name) for the single released tag. Leave empty for monorepos that dispatch per-path locally." + required: false + type: string + default: "" + secrets: + app_private_key: + description: "Private key for the GitHub App identified by app_id." + required: false + outputs: + releases_created: + description: "Whether release-please created a release." + value: ${{ jobs.release.outputs.releases_created }} + tag_name: + description: "Released tag name (empty / single-package repos)." + value: ${{ jobs.release.outputs.tag_name }} + paths_released: + description: "JSON array of package paths released (manifest/monorepo mode)." + value: ${{ jobs.release.outputs.paths_released }} + outputs: + description: "Full release-please outputs as a JSON object (for per-path tag lookup)." + value: ${{ jobs.release.outputs.outputs }} + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + releases_created: ${{ steps.rp.outputs.releases_created }} + tag_name: ${{ steps.rp.outputs['tag_name'] }} + paths_released: ${{ steps.rp.outputs.paths_released }} + outputs: ${{ toJSON(steps.rp.outputs) }} + steps: + # A GitHub App token is required for monorepos so the release PR + # push triggers downstream workflows (ci.yaml, pr-title-lint). + # GITHUB_TOKEN's anti-recursion guard would silently suppress them. + - uses: actions/create-github-app-token@v3 + id: app-token + if: inputs.app_id != '' + with: + app-id: ${{ inputs.app_id }} + private-key: ${{ secrets.app_private_key }} + + # Run release-please: opens/updates the release PR, or (when a release + # PR merges) tags the release. Uses the App token when provided so the + # release PR push can trigger downstream CI; otherwise GITHUB_TOKEN. + - uses: googleapis/release-please-action@v5 + id: rp + with: + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + + # Guard: stale release-as pins block future semver bumps. + # Only need the repo checked out to inspect the config/manifest, and + # only when no release was cut this run. + - uses: actions/checkout@v7 + if: inputs.guard_stale_release_as && steps.rp.outputs.releases_created != 'true' + - name: Guard against stale `release-as` entries + if: inputs.guard_stale_release_as && steps.rp.outputs.releases_created != 'true' + run: | + python3 - <<'PY' + import json, sys + with open("release-please-config.json") as f: + cfg = json.load(f) + with open(".release-please-manifest.json") as f: + manifest = json.load(f) + stale = [] + for pkg, conf in cfg.get("packages", {}).items(): + ra = conf.get("release-as") + if ra is not None and manifest.get(pkg) == ra: + stale.append((pkg, ra)) + if stale: + print("::error::Stale `release-as` entries in release-please-config.json:") + for pkg, ra in stale: + print(f"::error:: packages.{pkg}.release-as={ra} already matches .release-please-manifest.json — remove this field so release-please can resume normal semver bumps.") + sys.exit(1) + PY + + # Common single-tag case: dispatch the given workflows for the released + # tag. Monorepos leave dispatch_workflows empty and dispatch per-path in + # their own job instead (see the header example). + dispatch-publish: + needs: release + if: needs.release.outputs.releases_created == 'true' && inputs.dispatch_workflows != '' + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Dispatch downstream workflows for the released tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.release.outputs.tag_name }} + WORKFLOWS: ${{ inputs.dispatch_workflows }} + run: | + set -euo pipefail + if [[ -z "${TAG}" ]]; then + echo "::warning::release-please reported a release but no tag_name output; skipping dispatch" + exit 0 + fi + while IFS= read -r wf; do + wf="$(printf '%s' "${wf}" | xargs)" + [[ -z "${wf}" ]] && continue + echo "Dispatching ${wf} for ${TAG}" + gh workflow run "${wf}" \ + --ref "${TAG}" \ + -f tag_name="${TAG}" + done <<< "${WORKFLOWS}" diff --git a/.github/workflows/release-retag.yaml b/.github/workflows/release-retag.yaml new file mode 100644 index 0000000..18c03e3 --- /dev/null +++ b/.github/workflows/release-retag.yaml @@ -0,0 +1,208 @@ +# Reusable workflow: promote a release by retagging an existing +# `sha-` OCI image to its semver tag (+ `latest` for the +# highest stable release) and cosign-signing it. +# +# Language-agnostic — the source image is retagged by digest without a +# rebuild, so it works for any repo (python, node, etc.) that publishes +# `sha-` images via oci-build.yaml. +# +# Single-component caller (sugar-suite; tag `v1.2.3`): +# +# jobs: +# retag: +# uses: bcit-tlu/.github/.github/workflows/release-retag.yaml@main +# permissions: +# contents: read +# packages: write +# id-token: write +# with: +# image_name: sugar-suite +# tag_name: ${{ inputs.tag_name || github.event.release.tag_name }} +# secrets: inherit +# +# Monorepo caller (hriv; tags `-v1.2.3`) — call in a matrix: +# +# jobs: +# retag: +# strategy: +# matrix: +# component: [frontend, backend, backup, synthetic-monitoring] +# uses: bcit-tlu/.github/.github/workflows/release-retag.yaml@main +# permissions: +# contents: read +# packages: write +# id-token: write +# with: +# image_name: hriv-${{ matrix.component }} +# tag_prefix: ${{ matrix.component }}-v +# tag_name: ${{ inputs.tag_name || github.event.release.tag_name }} +# secrets: inherit + +name: Release retag + +on: + workflow_call: + inputs: + image_name: + description: "Image name under the repository namespace (e.g. sugar-suite)." + required: true + type: string + tag_name: + description: "Release tag to retag (e.g. v1.2.3 or backend-v1.2.3)." + required: true + type: string + tag_prefix: + description: "Tag prefix preceding the semver (e.g. 'v' or 'backend-v'). A tag not matching this prefix is skipped." + required: false + type: string + default: v + registry: + description: "OCI registry hosting the image." + required: false + type: string + default: ghcr.io + +permissions: + contents: read + +jobs: + retag: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + env: + REGISTRY: ${{ inputs.registry }} + steps: + # Extract the semver from the release tag using the caller's prefix. + # A tag that doesn't match (e.g. another component's) sets match=false + # so every subsequent step is skipped. + - name: Parse release tag + id: parse + env: + TAG: ${{ inputs.tag_name }} + PREFIX: ${{ inputs.tag_prefix }} + run: | + if [[ "$TAG" =~ ^${PREFIX}([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "version=${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" >> "$GITHUB_OUTPUT" + echo "match=true" >> "$GITHUB_OUTPUT" + else + echo "match=false" >> "$GITHUB_OUTPUT" + fi + + # Full history + tags are needed to compare against all prior releases + # (highest-stable check below) and to resolve the tag's commit SHA. + - uses: actions/checkout@v7 + if: steps.parse.outputs.match == 'true' + with: + ref: ${{ inputs.tag_name }} + fetch-depth: 0 + fetch-tags: true + + - name: Check if this is the highest stable release + id: highest + if: steps.parse.outputs.match == 'true' + shell: bash + env: + VERSION: ${{ steps.parse.outputs.version }} + PREFIX: ${{ inputs.tag_prefix }} + run: | + # Highest stable (non-prerelease) semver tag for this prefix. + HIGHEST=$(git tag -l "${PREFIX}[0-9]*.[0-9]*.[0-9]*" \ + | sed "s/^${PREFIX}//" \ + | grep -v -- '-' \ + | sort -V \ + | tail -n1) + + if [[ "${VERSION}" == "${HIGHEST}" ]]; then + echo "is_highest=true" >> "$GITHUB_OUTPUT" + echo "${VERSION} is the highest stable release — will tag as latest." + else + echo "is_highest=false" >> "$GITHUB_OUTPUT" + echo "${VERSION} is NOT the highest stable release (highest: ${HIGHEST}) — skipping latest tag." + fi + + # The source image was published as `sha-` by oci-build, so map + # the release tag back to its commit SHA to locate that image. + - name: Resolve tag commit SHA + if: steps.parse.outputs.match == 'true' + id: sha + env: + TAG: ${{ inputs.tag_name }} + run: | + SHA=$(git rev-list -n1 "${TAG}") + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + # Auth to the registry to inspect and retag the source image. + - uses: docker/login-action@v4 + if: steps.parse.outputs.match == 'true' + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Releases can be tagged before the commit's CI image finishes; poll + # (with backoff, 30m cap) until the `sha-` image exists. + - name: Wait for source image + if: steps.parse.outputs.match == 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ github.repository }}/${{ inputs.image_name }} + SHA: ${{ steps.sha.outputs.sha }} + run: | + SRC="${IMAGE}:sha-${SHA}" + DELAY=10 + MAX_DELAY=60 + DEADLINE=$(( $(date +%s) + 1800 )) + while :; do + if docker buildx imagetools inspect "${SRC}" >/dev/null 2>&1; then + echo "Source image ${SRC} is available." + break + fi + if [[ $(date +%s) -ge $DEADLINE ]]; then + echo "::error::Timed out after 30m waiting for ${SRC} (CI build for $SHA did not publish)." + exit 1 + fi + echo "Source image ${SRC} not yet available; retrying in ${DELAY}s..." + sleep "$DELAY" + DELAY=$(( DELAY * 2 )) + if (( DELAY > MAX_DELAY )); then DELAY=$MAX_DELAY; fi + done + + # Retag the existing image by digest (no rebuild/pull): add the semver + # tag, plus `latest` when this is the highest stable release. + - name: Retag (no pull) + id: retag + if: steps.parse.outputs.match == 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ github.repository }}/${{ inputs.image_name }} + SHA: ${{ steps.sha.outputs.sha }} + VERSION: ${{ steps.parse.outputs.version }} + IS_HIGHEST: ${{ steps.highest.outputs.is_highest }} + run: | + TAG_ARGS=( -t "${IMAGE}:${VERSION}" ) + if [[ "${IS_HIGHEST}" == "true" ]]; then + TAG_ARGS+=( -t "${IMAGE}:latest" ) + fi + + docker buildx imagetools create \ + "${TAG_ARGS[@]}" \ + "${IMAGE}:sha-${SHA}" + + # Capture digest for signing. + DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{.Manifest.Digest}}') + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + + # Sign with Cosign + - uses: sigstore/cosign-installer@v4.1.2 + if: steps.parse.outputs.match == 'true' + + # Keyless-sign the promoted image digest. + - name: Sign release image + if: steps.parse.outputs.match == 'true' && steps.retag.outputs.digest != '' + env: + IMAGE: ${{ steps.retag.outputs.image }} + DIGEST: ${{ steps.retag.outputs.digest }} + run: | + cosign sign --yes "${IMAGE}@${DIGEST}"