chore: release harbor chart 2.1.0 #97
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Preview Chart | |
| # Packages, pushes and signs a preview of deploy/chart for a pull request, | |
| # versioned <chart version>-pr.<N>, into the PR registry project, and leaves a | |
| # sticky comment with the install command. The chart-input allowlist lives in | |
| # the `changes` job, not in a `paths` trigger filter: GitHub evaluates `paths` | |
| # against the PR's own diff, which for a stacked PR is only the top slice. | |
| on: | |
| pull_request: | |
| # `stacked`: GitHub opens the PRs of a stack before it links them, so | |
| # `opened` never carries `pull_request.stack`; the link fires `stacked`. | |
| types: [opened, synchronize, reopened, stacked] | |
| branches: [main, "release-[0-9]*.[0-9]*"] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: pr-chart-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| REGISTRY_ADDRESS: ${{ vars.REGISTRY_ADDRESS || '8gears.container-registry.com' }} | |
| PR_REGISTRY_PROJECT: ${{ vars.PR_REGISTRY_PROJECT }} | |
| RELEASE_PROJECT: ${{ vars.REGISTRY_PROJECT || '8gcr' }} | |
| jobs: | |
| changes: | |
| name: Check chart inputs | |
| # Forked PRs have no registry credentials and bot PRs need no preview. A | |
| # chart release PR only restamps version and changelog. In a native | |
| # GitHub stack only the top PR publishes: its head contains every lower PR. | |
| if: >- | |
| github.event.pull_request.head.repo.full_name == github.repository && | |
| !contains(fromJSON('["8gcr-renovate[bot]", "renovate[bot]", "8gcr-sync[bot]"]'), github.actor) && | |
| !startsWith(github.head_ref, 'release-please--') && | |
| (github.event.pull_request.stack == null || | |
| github.event.pull_request.stack.position == github.event.pull_request.stack.size) | |
| runs-on: ubuntu-26.04 | |
| timeout-minutes: 5 | |
| outputs: | |
| build: ${{ steps.diff.outputs.build }} | |
| matched: ${{ steps.diff.outputs.matched }} | |
| stack_number: ${{ github.event.pull_request.stack.number || '' }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # Full history: the diff below spans the whole stack | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Diff against the stack base | |
| id: diff | |
| env: | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| STACK_BASE_SHA: ${{ github.event.pull_request.stack.base.sha || '' }} | |
| STACK_NUMBER: ${{ github.event.pull_request.stack.number || '' }} | |
| EVENT_ACTION: ${{ github.event.action }} | |
| # Only inputs that reach the packaged chart. | |
| CHART_INPUTS: | | |
| ^deploy/chart/ | |
| ^\.github/workflows/pr-chart\.yml$ | |
| ^\.github/scripts/chart-annotate-images\.sh$ | |
| run: | | |
| echo "event=${EVENT_ACTION} stack=${STACK_NUMBER:-none}" | |
| base="${STACK_BASE_SHA:-${PR_BASE_SHA}}" | |
| # --no-renames: a rename out of the allowlist would otherwise show | |
| # only its new path and hide that a chart input went away | |
| changed="$(git diff --name-only --no-renames "${base}...${HEAD_SHA}")" | |
| if matched="$(grep -Ef <(printf '%s' "${CHART_INPUTS}") <<<"${changed}")"; then | |
| echo "build=true" >> "$GITHUB_OUTPUT" | |
| # Named in the PR comment, so a PR that changes no chart input | |
| # itself can see why its stack still produces one. | |
| { | |
| echo "matched<<MATCHED_EOF" | |
| printf '%s\n' "${matched}" | |
| echo "MATCHED_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No chart input changed between ${base} and ${HEAD_SHA}; skipping the preview chart" | |
| echo "build=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| preview-chart: | |
| name: Publish preview chart | |
| needs: [changes] | |
| if: needs.changes.outputs.build == 'true' | |
| runs-on: ubuntu-26.04 | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| # cosign signs with the workflow's OIDC identity | |
| id-token: write | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| app_version: ${{ steps.version.outputs.app_version }} | |
| digest: ${{ steps.push.outputs.digest }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # The PR head, not the merge commit: the same ref the image previews build | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| - name: Load versions | |
| # Helm homes: self-hosted runner images may ship a root-owned | |
| # ~/.cache; keep all helm state in the always-writable temp. | |
| run: | | |
| grep -E '^[A-Z_]+=' versions.env >> "$GITHUB_ENV" | |
| { | |
| echo "HELM_CACHE_HOME=${RUNNER_TEMP}/helm/cache" | |
| echo "HELM_CONFIG_HOME=${RUNNER_TEMP}/helm/config" | |
| echo "HELM_DATA_HOME=${RUNNER_TEMP}/helm/data" | |
| } >> "$GITHUB_ENV" | |
| - name: Install Helm | |
| uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 | |
| with: | |
| version: ${{ env.HELM_VERSION }} | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Resolve the preview version | |
| id: version | |
| # <chart version>-pr.<N>: a semver prerelease, so helm accepts it as an | |
| # exact --version and the OCI tag is the version string itself. It is | |
| # overwritten on every push; the comment carries the digest for pinning. | |
| env: | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -euo pipefail | |
| read_key() { | |
| awk -F'[:[:space:]]+' -v key="$1" '$1 == key { gsub(/["'"'"']/, "", $2); print $2; exit }' deploy/chart/Chart.yaml | |
| } | |
| version="$(read_key version)" | |
| app_version="$(read_key appVersion)" | |
| if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::error::Unusable chart version in deploy/chart/Chart.yaml: '${version}'" | |
| exit 1 | |
| fi | |
| preview="${version}-pr.${PR_NUMBER}" | |
| echo "PREVIEW_VERSION=${preview}" >> "$GITHUB_ENV" | |
| { | |
| echo "version=${preview}" | |
| echo "app_version=${app_version}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Add per-release Artifact Hub images annotation | |
| # Before the login on purpose: the script comes from the pull request, | |
| # and PR-controlled code must not run on a runner that already holds | |
| # registry credentials. The annotation names the images the chart | |
| # defaults to, the released appVersion in the release project. | |
| run: | | |
| .github/scripts/chart-annotate-images.sh \ | |
| deploy/chart "${REGISTRY_ADDRESS}" "${RELEASE_PROJECT}" | |
| - name: Log in to registry | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ${{ env.REGISTRY_ADDRESS }} | |
| username: ${{ vars.PR_REGISTRY_USERNAME }} | |
| password: ${{ secrets.PR_REGISTRY_PASSWORD }} | |
| - name: Build chart dependencies | |
| # deploy/chart/charts/ is gitignored; Chart.lock pins the subcharts. | |
| run: helm dependency build deploy/chart | |
| - name: Package chart | |
| # appVersion stays as committed, so default image tags resolve to a | |
| # published Harbor release. | |
| run: helm package deploy/chart --version "${PREVIEW_VERSION}" --destination dist | |
| - name: Push chart | |
| id: push | |
| # pipefail: GitHub's default shell is `bash -e` without it, so a | |
| # failing helm push would be masked by tee, and its error message can | |
| # itself contain a sha256 digest the capture below would match. | |
| run: | | |
| set -euo pipefail | |
| helm push "dist/harbor-next-${PREVIEW_VERSION}.tgz" \ | |
| "oci://${REGISTRY_ADDRESS}/${PR_REGISTRY_PROJECT}/charts" 2>&1 | tee push.log | |
| digest=$(grep -o 'sha256:[a-f0-9]\{64\}' push.log | head -1) | |
| if [ -z "$digest" ]; then | |
| echo "::error::Failed to capture the chart digest from helm push output" | |
| exit 1 | |
| fi | |
| echo "digest=${digest}" >> "$GITHUB_OUTPUT" | |
| - name: Sign chart | |
| env: | |
| CHART_DIGEST: ${{ steps.push.outputs.digest }} | |
| run: | | |
| cosign sign --yes \ | |
| "${REGISTRY_ADDRESS}/${PR_REGISTRY_PROJECT}/charts/harbor-next@${CHART_DIGEST}" | |
| pr-comment: | |
| name: Comment preview chart ref | |
| needs: [changes, preview-chart] | |
| # Also runs when nothing was published: a build that is skipped, fails, or | |
| # belongs to a PR that is not the top of its stack leaves an earlier | |
| # reference standing, and the comment has to say that it is stale. | |
| if: >- | |
| always() && | |
| github.event.pull_request.head.repo.full_name == github.repository && | |
| !contains(fromJSON('["8gcr-renovate[bot]", "renovate[bot]", "8gcr-sync[bot]"]'), github.actor) | |
| runs-on: ubuntu-26.04 | |
| timeout-minutes: 5 | |
| permissions: | |
| pull-requests: write | |
| env: | |
| PREVIEW_VERSION: ${{ needs.preview-chart.outputs.version }} | |
| APP_VERSION: ${{ needs.preview-chart.outputs.app_version }} | |
| DIGEST: ${{ needs.preview-chart.outputs.digest }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| STACK_NUMBER: ${{ github.event.pull_request.stack.number || '' }} | |
| MATCHED: ${{ needs.changes.outputs.matched }} | |
| CHANGES_RESULT: ${{ needs.changes.result }} | |
| CHANGES_BUILD: ${{ needs.changes.outputs.build }} | |
| BUILD_RESULT: ${{ needs.preview-chart.result }} | |
| steps: | |
| - name: Create or update PR comment | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| MARKER: "<!-- harbor-next-pr-chart-preview -->" | |
| SIBLING_MARKER: "<!-- harbor-next-pr-preview -->" | |
| with: | |
| script: | | |
| const marker = process.env.MARKER; | |
| const siblingMarker = process.env.SIBLING_MARKER; | |
| const staleStart = '<!-- preview:stale-start -->'; | |
| const staleEnd = '<!-- preview:stale-end -->'; | |
| const stackNumber = process.env.STACK_NUMBER; | |
| const short = process.env.HEAD_SHA.slice(0, 7); | |
| const repository = process.env.GITHUB_REPOSITORY; | |
| const issueNumber = context.payload.pull_request.number; | |
| const { owner, repo } = context.repo; | |
| const previewProject = process.env.PR_REGISTRY_PROJECT; | |
| const chart = `${process.env.REGISTRY_ADDRESS}/${previewProject}/charts/harbor-next`; | |
| const previewVersion = process.env.PREVIEW_VERSION; | |
| const appVersion = process.env.APP_VERSION; | |
| const digest = process.env.DIGEST; | |
| const stack = stackNumber | |
| ? await github.request('GET /repos/{owner}/{repo}/stacks/{stack_number}', { | |
| owner, repo, stack_number: Number(stackNumber), | |
| }).then(response => response.data).catch(error => { | |
| core.warning(`Could not list stack #${stackNumber}: ${error.message}`); | |
| return null; | |
| }) | |
| : null; | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, repo, issue_number: issueNumber, per_page: 100, | |
| }); | |
| const existing = comments.find(comment => | |
| comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(marker)); | |
| const post = async body => { | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| return; | |
| } | |
| await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body }); | |
| }; | |
| // The notice is delimited rather than pattern-matched, so a second | |
| // one replaces the first instead of stacking under it. | |
| const withoutNotice = body => { | |
| const kept = []; | |
| let inNotice = false; | |
| for (const line of body.split('\n')) { | |
| if (line === staleStart) { inNotice = true; continue; } | |
| if (line === staleEnd) { inNotice = false; continue; } | |
| // `:stale` also matches the inline marker the first | |
| // implementation used, so an old notice is not kept forever. | |
| if (!inNotice && line !== marker && !/^<!--[^>]*:stale/.test(line)) kept.push(line); | |
| } | |
| return kept.join('\n').trim(); | |
| }; | |
| if (process.env.BUILD_RESULT !== 'success') { | |
| // Nothing was published, so there is nothing to correct unless an | |
| // earlier revision left a reference behind. | |
| if (!existing) return; | |
| let reason; | |
| if (process.env.CHANGES_RESULT === 'skipped' && stackNumber) { | |
| const top = stack?.pull_requests?.[stack.pull_requests.length - 1]; | |
| reason = `this PR is not the top of stack #${stackNumber}` + | |
| (top ? `, so the stack's preview chart is published on #${top.number}` : ''); | |
| } else if (process.env.CHANGES_RESULT !== 'success') { | |
| // A failed or cancelled input check publishes nothing either, | |
| // and must not be reported as "no input changed". | |
| reason = `the chart input check for \`${short}\` did not complete (${process.env.CHANGES_RESULT})`; | |
| } else if (process.env.CHANGES_BUILD !== 'true') { | |
| reason = `no chart input changed between the base and \`${short}\``; | |
| } else { | |
| // The publish step repoints the mutable reference before it | |
| // signs, so a build that got that far has already replaced it. | |
| reason = `the preview chart build for \`${short}\` did not succeed (${process.env.BUILD_RESULT}). ` + | |
| 'That build may already have repointed the preview reference, so trust the digest below rather than the tag'; | |
| } | |
| await post([ | |
| marker, | |
| staleStart, | |
| '> [!WARNING]', | |
| `> **Outdated:** ${reason}. What follows is from an earlier revision of this PR.`, | |
| staleEnd, | |
| '', | |
| withoutNotice(existing.body), | |
| ].join('\n')); | |
| return; | |
| } | |
| const matched = (process.env.MATCHED || '').split('\n').map(line => line.trim()).filter(Boolean); | |
| const shown = matched.slice(0, 5).map(path => `\`${path}\``).join(', '); | |
| const more = matched.length > 5 ? ` and ${matched.length - 5} more` : ''; | |
| const stackSection = []; | |
| if (stackNumber) { | |
| stackSection.push('', `This PR is the top of stack #${stackNumber}, so the chart comes from a head that contains every PR in it:`, ''); | |
| stackSection.push(...(stack?.pull_requests?.length | |
| ? stack.pull_requests.map((pr, index) => `${index + 1}. #${pr.number} ${pr.title}`) | |
| : ['(see the stack view on this PR)'])); | |
| if (matched.length) { | |
| stackSection.push('', `Built because ${shown}${more} changed somewhere in the stack.`); | |
| } | |
| stackSection.push('', 'Lower PRs publish nothing of their own; push a fix there, then `gh stack rebase && gh stack push` to rebuild this one.'); | |
| } | |
| const sibling = comments.find(comment => | |
| comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(siblingMarker)); | |
| const siblingSection = sibling | |
| ? ['', sibling.body.includes(staleStart) | |
| ? `Preview images for this PR have a comment, but it is currently marked outdated: ${sibling.html_url}` | |
| : `Preview images for this PR are published too: ${sibling.html_url}`] | |
| : []; | |
| const body = [ | |
| marker, | |
| `A preview chart for this PR is available, packaged from \`${short}\`:`, | |
| '', | |
| '```', | |
| 'helm install harbor-next \\', | |
| ` oci://${chart} \\`, | |
| ` --version ${previewVersion}`, | |
| '```', | |
| '', | |
| `Digest: \`${digest}\`. The chart installs Harbor \`${appVersion}\` as committed. This PR's \`pr-${issueNumber}\` images, when published, live in \`${previewProject}\` and are not selected by this command: point the chart at them with per-component \`image.registry\` and \`image.repository\` values.`, | |
| ...stackSection, | |
| ...siblingSection, | |
| '', | |
| 'Verify the preview chart:', | |
| '', | |
| '```', | |
| 'cosign verify \\', | |
| ` --certificate-identity-regexp="https://github.com/${repository}/.github/workflows/pr-chart.yml@.*" \\`, | |
| ' --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \\', | |
| ` ${chart}@${digest}`, | |
| '```', | |
| ].join('\n'); | |
| await post(body); |