Skip to content

chore: release harbor chart 2.1.0 #97

chore: release harbor chart 2.1.0

chore: release harbor chart 2.1.0 #97

Workflow file for this run

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);