Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions .github/workflows/pr-chart.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
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 ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a native stack is being created, the opened event has pull_request.stack == null, so this condition lets every lower PR publish before the stacked event arrives. Defer publication for stack creation or otherwise distinguish an unlinked stack from a plain PR to preserve the top-only guarantee.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/pr-chart.yml, line 38:

<comment>When a native stack is being created, the `opened` event has `pull_request.stack == null`, so this condition lets every lower PR publish before the `stacked` event arrives. Defer publication for stack creation or otherwise distinguish an unlinked stack from a plain PR to preserve the top-only guarantee.</comment>

<file context>
@@ -0,0 +1,360 @@
+      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
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reachable for the members that matter. Until the stack is linked, every PR above the bottom has its base set to the branch below, which does not match the branches filter, so no run starts. The bottom PR does run, and at that moment it is a plain PR targeting the trunk, which is the correct verdict. Observed on the stack that produced this design: the top PR got no run at opened and its first run came from stacked.

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 }}
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 grep -Ef <(printf '%s' "${CHART_INPUTS}") <<<"${changed}"; then
echo "build=true" >> "$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 envsubst
run: command -v envsubst || (sudo apt-get update && sudo apt-get install -y gettext-base)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

- 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Enforce Strict Maintainability Standards

When Chart.yaml uses a valid quoted top-level key, this awk parser returns an empty value because it only matches unquoted $1. Use a YAML-aware metadata extractor and share it with chart-annotate-images.sh instead of duplicating this formatting-sensitive parser.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/pr-chart.yml, line 125:

<comment>When `Chart.yaml` uses a valid quoted top-level key, this `awk` parser returns an empty value because it only matches unquoted `$1`. Use a YAML-aware metadata extractor and share it with `chart-annotate-images.sh` instead of duplicating this formatting-sensitive parser.</comment>

<file context>
@@ -0,0 +1,267 @@
+        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)"
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied: both lines are copied verbatim from the release workflow, which has published every chart in this repository. Changing the preview alone would create the divergence the maintainability rule is meant to prevent; if the parser is wrong it is wrong in the release path first, and that is where it should be fixed.

}
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: 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: Add per-release Artifact Hub images annotation
# The annotation names the images the chart defaults to: the released
# appVersion in the release project, not the preview project.
run: |
.github/scripts/chart-annotate-images.sh \
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
deploy/chart "${REGISTRY_ADDRESS}" "${RELEASE_PROJECT}"

- 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" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Enforce Strict Maintainability Standards

This scrapes free-form helm push output and takes the first SHA token, so output changes or SHA-bearing diagnostics can make cosign target the wrong digest. Use Helm's --output json result and extract .digest with jq instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/pr-chart.yml, line 170:

<comment>This scrapes free-form `helm push` output and takes the first SHA token, so output changes or SHA-bearing diagnostics can make cosign target the wrong digest. Use Helm's `--output json` result and extract `.digest` with `jq` instead.</comment>

<file context>
@@ -0,0 +1,267 @@
+        # 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)
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied: both lines are copied verbatim from the release workflow, which has published every chart in this repository. Changing the preview alone would create the divergence the maintainability rule is meant to prevent; if the parser is wrong it is wrong in the release path first, and that is where it should be fixed.

"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]
if: needs.changes.outputs.build == 'true'
runs-on: ubuntu-26.04
permissions:
pull-requests: write

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The comment job uses the Issues comments API but grants only pull-requests: write, so publishing can succeed while the sticky chart comment fails with a 403. Add issues: write to this job's permissions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/pr-chart.yml, line 198:

<comment>The comment job uses the Issues comments API but grants only `pull-requests: write`, so publishing can succeed while the sticky chart comment fails with a 403. Add `issues: write` to this job's permissions.</comment>

<file context>
@@ -0,0 +1,273 @@
+    if: needs.changes.outputs.build == 'true'
+    runs-on: ubuntu-26.04
+    permissions:
+      pull-requests: write
+    env:
+      PREVIEW_VERSION: ${{ needs.preview-chart.outputs.version }}
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied, disproven in practice. Every preview comment in these repositories is posted by a job holding only pull-requests: write, including the ones on this pull request. GitHub treats a pull request comment as a pull request write, not an issue write.

env:
PREVIEW_VERSION: ${{ needs.preview-chart.outputs.version }}
APP_VERSION: ${{ needs.preview-chart.outputs.app_version }}
DIGEST: ${{ needs.preview-chart.outputs.digest }}
STACK_NUMBER: ${{ needs.changes.outputs.stack_number }}
steps:
- name: Create or update PR comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MARKER: "<!-- harbor-next-pr-chart-preview -->"
with:
script: |
const marker = process.env.MARKER;
const registryAddress = process.env.REGISTRY_ADDRESS;
const previewProject = process.env.PR_REGISTRY_PROJECT;
const chart = `${registryAddress}/${previewProject}/charts/harbor-next`;
const previewVersion = process.env.PREVIEW_VERSION;
const appVersion = process.env.APP_VERSION;
const digest = process.env.DIGEST;
const stackNumber = process.env.STACK_NUMBER;
const repository = process.env.GITHUB_REPOSITORY;
const issueNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const stackNote = stackNumber
? `\nThis PR is the top of stack #${stackNumber}; the chart is packaged from its head and contains every PR in the stack.\n`
: '';

const body = `${marker}
A preview chart for this PR is available:

\`\`\`
helm install harbor-next \\
oci://${chart} \\
--version ${previewVersion}
\`\`\`

Digest: \`${digest}\`. The chart defaults to Harbor \`${appVersion}\`; this PR's \`pr-${issueNumber}\` images in \`${previewProject}\`, when published, can be selected through the chart's image values.
${stackNote}
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}
\`\`\``;

const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});

const existingComment = comments.find(comment =>
comment.user?.login === 'github-actions[bot]' &&
comment.body?.startsWith(marker)
);

if (existingComment) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
return;
}

await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
Loading