From b900f79f837f0a3c592caca16bebe160c90f3aac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 20:43:22 +0000 Subject: [PATCH] ci: standardize workflows and chart patterns to match org playbook - Delete build-and-push-app.yaml.inactive and VERSION file - Fix helm push error guard: use || { echo; exit 1; } pattern - Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to all workflows - Use shell-safe env: blocks for GHA expressions - Add digest guard to release-retag sign step - Fix deployment.yaml affinity check: use ne (toJson) instead of if - Add x-release-please-version annotation to Chart.yaml version line - Fix Dockerfile comments (## -> #) - Update AGENTS.md to reflect manifest-based version tracking - Reduce verbose block comments across workflows Co-Authored-By: kyle_hunter@bcit.ca --- .../build-and-push-app.yaml.inactive | 24 ------- .github/workflows/ci.yaml | 71 ++++++++----------- .github/workflows/helm-publish.yaml | 47 ++++++++---- .github/workflows/pr-title-lint.yaml | 30 ++++---- .github/workflows/release-please.yaml | 24 +++---- .github/workflows/release-retag.yaml | 46 ++++++------ AGENTS.md | 5 +- Dockerfile | 4 +- VERSION | 1 - charts/Chart.yaml | 2 +- charts/templates/deployment.yaml | 2 +- 11 files changed, 119 insertions(+), 137 deletions(-) delete mode 100644 .github/workflows/build-and-push-app.yaml.inactive delete mode 100644 VERSION diff --git a/.github/workflows/build-and-push-app.yaml.inactive b/.github/workflows/build-and-push-app.yaml.inactive deleted file mode 100644 index 27c277d..0000000 --- a/.github/workflows/build-and-push-app.yaml.inactive +++ /dev/null @@ -1,24 +0,0 @@ -# Runs `.github/workflows/build-and-push-app.yaml` from bcit-ltc/.github -name: Build & ship app/chart images - -on: - push: - branches: [ main ] - workflow_dispatch: - -jobs: - build-and-ship: - uses: bcit-ltc/.github/.github/workflows/build-and-push-app.yaml@main - permissions: - contents: write - packages: write - id-token: write - attestations: write - pull-requests: write - issues: write - with: - cdn_enabled: true - # Override asset extensions: add eot, remove unused (jpeg,webp,webm,mp3,ogg) - # Includes compressed files (.br, .gz) for proper CDN serving - # cdn_asset_exts: jpg,png,gif,svg,ico,mp4,woff,woff2,ttf,otf,eot,css,js,map - secrets: inherit diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8d094c..d2d3e64 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,6 @@ name: "[CI] Lint, build & publish" -# Readable run titles in the Actions UI: PR runs show the PR number and -# title; main pushes show the head commit subject. +# PR runs show PR title; main runs show head commit subject. run-name: >- ${{ github.event_name == 'pull_request' && format('PR #{0} — {1}', github.event.pull_request.number, github.event.pull_request.title) @@ -16,18 +15,12 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true -# Least-privilege default: only read access at the workflow level. -# Jobs that need more elevate locally. +# Default to least privilege; jobs elevate only when needed. permissions: contents: read jobs: - # ── Quality gates ───────────────────────────────────────── - # - # Lint, test, and Helm lint run on every push + PR as parallel - # quality gates. Their failure breaks the workflow status but - # does not block the image build (surfaced via branch protection - # required status checks). + # Quality gates test: runs-on: ubuntu-latest @@ -66,16 +59,7 @@ jobs: -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ -ignore-missing-schemas - # ── Container image ────────────────────────────────────── - # - # The shared reusable workflow handles checkout, change - # detection, Docker build/push, Cosign signing, and Trivy - # scanning. - # - # `helm-lint` is intentionally NOT in `needs` — it runs as an - # independent parallel job whose failure surfaces via branch - # protection (required status check) rather than delaying or - # blocking image builds. + # Container image build/publish (reusable workflow) build-open-data: uses: bcit-tlu/.github/.github/workflows/oci-build.yaml@main @@ -92,12 +76,7 @@ jobs: tag_prefix: "v" secrets: inherit - # ── Helm chart → OCI registry (main push) ───────────────── - # - # Publishes the chart with the same RC version that the image - # received so the chart and app tags stay in lockstep. Only - # runs on main pushes when the component actually changed. - # Release-time chart publishing is handled by helm-publish.yaml. + # Publish chart from main pushes using the image RC version. helm-publish: needs: [helm-lint, build-open-data] @@ -119,30 +98,38 @@ jobs: - uses: sigstore/cosign-installer@v3 - name: Cosign login (OCI) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - cosign login "${{ env.REGISTRY }}" \ - -u "${{ github.actor }}" \ + echo "${GITHUB_TOKEN}" | \ + cosign login "${REGISTRY}" \ + -u "${ACTOR}" \ --password-stdin - name: Helm login (OCI) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - helm registry login "${{ env.REGISTRY }}" \ - -u "${{ github.actor }}" \ + echo "${GITHUB_TOKEN}" | \ + helm registry login "${REGISTRY}" \ + -u "${ACTOR}" \ --password-stdin - name: Package, push & sign chart shell: bash env: VERSION: ${{ needs.build-open-data.outputs.rc_version }} + OCI_BASE: oci://${{ env.REGISTRY }}/${{ github.repository }}/charts + IMAGE_BASE: ${{ env.REGISTRY }}/${{ github.repository }}/charts run: | set -euo pipefail - OCI_BASE="oci://${{ env.REGISTRY }}/${{ github.repository }}/charts" - IMAGE_BASE="${{ env.REGISTRY }}/${{ github.repository }}/charts" CHART_DIR="charts" - CHART_NAME=$(awk '/^name:/{print $2; exit}' "${CHART_DIR}/Chart.yaml") + CHART_NAME=$(yq '.name' "${CHART_DIR}/Chart.yaml") DEST_DIR="/tmp/charts" mkdir -p "${DEST_DIR}" @@ -153,11 +140,15 @@ jobs: PUSH_OUT=$(helm push \ "${DEST_DIR}/${CHART_NAME}-${VERSION}.tgz" \ - "${OCI_BASE}" 2>&1) || true + "${OCI_BASE}" 2>&1) || { echo "${PUSH_OUT}"; exit 1; } echo "${PUSH_OUT}" - printf '%s\n' "${PUSH_OUT}" | grep -q '^Digest:' || { echo "::error::helm push failed for ${CHART_NAME}"; exit 1; } - DIGEST=$(printf '%s\n' "${PUSH_OUT}" | awk '/^Digest:/{print $2}') - if [[ -n "${DIGEST}" ]]; then - cosign sign --yes "${IMAGE_BASE}/${CHART_NAME}@${DIGEST}" + # 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}" diff --git a/.github/workflows/helm-publish.yaml b/.github/workflows/helm-publish.yaml index 7556d4c..a97c6ee 100644 --- a/.github/workflows/helm-publish.yaml +++ b/.github/workflows/helm-publish.yaml @@ -17,6 +17,7 @@ permissions: env: REGISTRY: ghcr.io + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: publish: @@ -46,31 +47,42 @@ jobs: - name: Cosign login (OCI) if: steps.parse.outputs.publish == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - cosign login "${{ env.REGISTRY }}" \ - -u "${{ github.actor }}" \ + echo "${GITHUB_TOKEN}" | \ + cosign login "${REGISTRY}" \ + -u "${ACTOR}" \ --password-stdin - name: Helm login (OCI) if: steps.parse.outputs.publish == 'true' shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY: ${{ env.REGISTRY }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | \ - helm registry login "${{ env.REGISTRY }}" \ - -u "${{ github.actor }}" \ + echo "${GITHUB_TOKEN}" | \ + helm registry login "${REGISTRY}" \ + -u "${ACTOR}" \ --password-stdin - name: Package and push chart id: push if: steps.parse.outputs.publish == 'true' shell: bash + env: + VERSION: ${{ steps.parse.outputs.version }} + OCI_REPO: oci://${{ env.REGISTRY }}/${{ github.repository }}/charts run: | set -euo pipefail - VERSION="${{ steps.parse.outputs.version }}" CHART_DIR="charts" DEST_DIR="/tmp/charts" + CHART_NAME=$(yq '.name' "${CHART_DIR}/Chart.yaml") mkdir -p "${DEST_DIR}" @@ -80,16 +92,25 @@ jobs: -d "${DEST_DIR}" PUSH_OUT=$(helm push \ - "${DEST_DIR}/open-data-${VERSION}.tgz" \ - "oci://${{ env.REGISTRY }}/${{ github.repository }}/charts" 2>&1) || true + "${DEST_DIR}/${CHART_NAME}-${VERSION}.tgz" \ + "${OCI_REPO}" 2>&1) || { echo "${PUSH_OUT}"; exit 1; } echo "${PUSH_OUT}" - printf '%s\n' "${PUSH_OUT}" | grep -q '^Digest:' || { echo '::error::helm push failed'; exit 1; } - DIGEST=$(printf '%s\n' "${PUSH_OUT}" | awk '/^Digest:/{print $2}') + # 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 succeeded but no digest found in output (helm output format may have changed)" + exit 1 + fi + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "chart_name=${CHART_NAME}" >> "$GITHUB_OUTPUT" - name: Sign chart if: steps.parse.outputs.publish == 'true' && steps.push.outputs.digest != '' + env: + CHART_REF: ${{ env.REGISTRY }}/${{ github.repository }}/charts/${{ steps.push.outputs.chart_name }} + DIGEST: ${{ steps.push.outputs.digest }} run: | - cosign sign --yes \ - "${{ env.REGISTRY }}/${{ github.repository }}/charts/open-data@${{ steps.push.outputs.digest }}" + cosign sign --yes "${CHART_REF}@${DIGEST}" diff --git a/.github/workflows/pr-title-lint.yaml b/.github/workflows/pr-title-lint.yaml index d785913..5749d23 100644 --- a/.github/workflows/pr-title-lint.yaml +++ b/.github/workflows/pr-title-lint.yaml @@ -1,15 +1,15 @@ name: pr-title-lint -# Gate non-conventional PR titles at the merge boundary so unparseable -# squash-merge commits can't land on main and silently starve -# release-please of the feat:/fix:/BREAKING signals it uses to open -# release PRs. +# Enforce Conventional Commit PR titles so release-please can parse changes. on: pull_request_target: types: [opened, edited, reopened, synchronize] branches: [main] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + permissions: contents: read pull-requests: write @@ -49,25 +49,25 @@ jobs: with: header: pr-title-lint message: | - ## PR title does not match Conventional Commits - - **Why this matters (tl;dr)** — release-please reads PR titles on `main` to decide whether to cut a release, what version bump to apply, and how to group CHANGELOG entries. A title that doesn't parse is silently dropped; no release PR opens, no error, workflow still exits 0. + ## PR title must follow Conventional Commits - **Required structure** + release-please uses PR titles on `main` to determine version bumps and changelog entries. ``` [optional scope][!]: ``` - - `` — `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` / `revert` - - `` — lowercase first letter, imperative mood, no trailing period - - add `!` (or `BREAKING CHANGE:` in the body) for breaking changes - - **Good** — `feat: add search page`, `fix: correct broken nav link`, `ci: tighten workflow permissions` + - `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) - **Bad** — `Add search page` (no type), `feat: Add search page` (uppercase subject) + Examples: + - ✅ `feat: add search page` + - ✅ `fix: correct broken nav link` + - ❌ `Add search page` + - ❌ `feat: Add search page` - Edit the PR title in the GitHub UI — the check re-runs automatically within seconds, no push needed. + Edit the PR title in GitHub; this check reruns automatically. - name: Clear failure explainer on success if: success() && steps.lint.outcome == 'success' diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 269e967..a7ef09f 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -4,11 +4,13 @@ on: push: branches: [main] -# Least-privilege default: only `checkout` needs read access. -# Each job elevates permissions to just what that job requires. +# Default to least privilege; jobs elevate only when needed. permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: release: runs-on: ubuntu-latest @@ -25,17 +27,8 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - # `release-as` per-package is a one-shot: once the release PR it - # triggered has merged and the manifest has caught up, the override - # must be removed, otherwise every subsequent release-please run - # keeps pinning the same version and blocks normal semver bumps - # from feat:/fix: commits. This guard runs *after* release-please - # and is skipped whenever `releases_created=true`, so the - # tag/release finalization that happens on the merge-of-chore-PR - # push (where manifest has just caught up to release-as) isn't - # blocked. On any subsequent push where the state is still stale - # and there's nothing to release, the workflow fails loudly to - # force the cleanup PR. + # `release-as` is one-shot. Once manifest catches up, remove it; + # otherwise future semver bumps are pinned and releases stall. - uses: actions/checkout@v6 if: steps.rp.outputs.releases_created != 'true' - name: Guard against stale `release-as` entries @@ -59,9 +52,8 @@ jobs: sys.exit(1) PY - # Releases created via GITHUB_TOKEN do NOT trigger workflows that listen - # on `release: published` or `push: tags:`. Dispatch helm-publish and - # release-retag explicitly for the released tag. + # Releases made with GITHUB_TOKEN do not trigger release/tag workflows. + # Dispatch publish workflows explicitly for the new tag. dispatch-publish: needs: release if: needs.release.outputs.releases_created == 'true' diff --git a/.github/workflows/release-retag.yaml b/.github/workflows/release-retag.yaml index ccec708..8c1516a 100644 --- a/.github/workflows/release-retag.yaml +++ b/.github/workflows/release-retag.yaml @@ -29,10 +29,10 @@ jobs: TAG: ${{ inputs.tag_name || github.event.release.tag_name }} run: | if [[ "$TAG" =~ ^v([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 + echo "version=${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" >> "$GITHUB_OUTPUT" + echo "match=true" >> "$GITHUB_OUTPUT" else - echo "match=false" >> $GITHUB_OUTPUT + echo "match=false" >> "$GITHUB_OUTPUT" fi - uses: actions/checkout@v6 @@ -46,11 +46,10 @@ jobs: id: highest if: steps.parse.outputs.match == 'true' shell: bash + env: + VERSION: ${{ steps.parse.outputs.version }} run: | - VERSION=${{ steps.parse.outputs.version }} - - # List all stable (non-prerelease) tags, strip prefix, sort by - # semver, take the highest. + # Highest stable (non-prerelease) semver tag. HIGHEST=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" \ | sed "s/^v//" \ | grep -v -- '-' \ @@ -72,7 +71,7 @@ jobs: TAG: ${{ inputs.tag_name || github.event.release.tag_name }} run: | SHA=$(git rev-list -n1 "${TAG}") - echo "sha=${SHA}" >> $GITHUB_OUTPUT + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v4 if: steps.parse.outputs.match == 'true' @@ -112,31 +111,34 @@ jobs: - name: Retag (no pull) id: retag if: steps.parse.outputs.match == 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ github.repository }}/open-data + SHA: ${{ steps.sha.outputs.sha }} + VERSION: ${{ steps.parse.outputs.version }} + IS_HIGHEST: ${{ steps.highest.outputs.is_highest }} run: | - IMAGE=${{ env.REGISTRY }}/${{ github.repository }}/open-data - SHA=${{ steps.sha.outputs.sha }} - VERSION=${{ steps.parse.outputs.version }} - - TAGS="-t ${IMAGE}:${VERSION}" - if [[ "${{ steps.highest.outputs.is_highest }}" == "true" ]]; then - TAGS="${TAGS} -t ${IMAGE}:latest" + TAGS=( -t "${IMAGE}:${VERSION}" ) + if [[ "${IS_HIGHEST}" == "true" ]]; then + TAGS+=( -t "${IMAGE}:latest" ) fi docker buildx imagetools create \ - ${TAGS} \ - ${IMAGE}:sha-${SHA} + "${TAGS[@]}" \ + "${IMAGE}:sha-${SHA}" - # Capture digest for Cosign signing. + # 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 (keyless / Sigstore) ────────────── + # Sign with Cosign - uses: sigstore/cosign-installer@v3 if: steps.parse.outputs.match == 'true' - name: Sign release image - if: steps.parse.outputs.match == 'true' + 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 \ - ${{ steps.retag.outputs.image }}@${{ steps.retag.outputs.digest }} + cosign sign --yes "${IMAGE}@${DIGEST}" diff --git a/AGENTS.md b/AGENTS.md index 61d6c13..e26b4a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,8 +58,9 @@ helm template test charts/ | kubeconform -strict -summary \ - **PR titles**: Must follow conventional commit format (enforced by CI) - **Branching**: `devin/-` - **License**: MPL-2.0 -- **Versioning**: Managed by release-please; `VERSION` file is source of truth -- **Chart appVersion**: Annotated with `# x-release-please-version` +- **Versioning**: Managed by release-please; version tracked via `.release-please-manifest.json` and `# x-release-please-version` annotations in Chart.yaml +- **Helm lint**: `helm lint charts/` +- **Helm validate**: `helm template test charts/ | kubeconform -strict -summary -schema-location default -ignore-missing-schemas` ## CI/CD pipeline diff --git a/Dockerfile b/Dockerfile index 3d7cd82..b803b46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -## Build stage +# Build stage FROM node:24-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS builder WORKDIR /app @@ -10,7 +10,7 @@ COPY . /app RUN npm run build -## Release/production +# Release/production FROM nginxinc/nginx-unprivileged:alpine3.22-perl@sha256:f1444b4f78f91b0c42dedc01b55972f4d759e7fcbabdf5d5a5e2f0690234eef4 LABEL maintainer=courseproduction@bcit.ca diff --git a/VERSION b/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/charts/Chart.yaml b/charts/Chart.yaml index ddcff81..42dcbe1 100644 --- a/charts/Chart.yaml +++ b/charts/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: open-data description: Open Data Portal — a Docusaurus-based open data portal for learning analytics datasets. type: application -version: 0.1.0 +version: 0.1.0 # x-release-please-version appVersion: "0.1.2" # x-release-please-version diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 4eeb04e..ef8644e 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -29,7 +29,7 @@ spec: {{- $merged := dict "podAntiAffinity" (dict "requiredDuringSchedulingIgnoredDuringExecution" (append $existing $zoneRule)) }} {{- $aff = mustMergeOverwrite $aff $merged }} {{- end }} - {{- if $aff }} + {{- if ne (toJson $aff) "{}" }} affinity: {{- toYaml $aff | nindent 8 }} {{- end }}