Skip to content

fix(api): Reject query operands the column cannot take #1685

fix(api): Reject query operands the column cannot take

fix(api): Reject query operands the column cannot take #1685

Workflow file for this run

name: PR Preview Images
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]*"]
# No `paths` filter: GitHub evaluates it against the PR's own diff, which
# for a stacked PR is only the top slice. The image-input allowlist lives
# in the `changes` job below, which diffs against the stack base instead.
permissions:
contents: read
concurrency:
group: pr-preview-${{ 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 }}
PREVIEW_TAG: pr-${{ github.event.pull_request.number }}
jobs:
changes:
name: Check image inputs
# Forked PRs have no registry credentials and bot PRs need no preview. In
# a native GitHub stack only the top PR builds: its head already contains
# every lower PR, so each lower image would be a prefix of it.
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
!contains(fromJSON('["8gcr-renovate[bot]", "renovate[bot]", "8gcr-sync[bot]"]'), github.actor) &&
(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 }}
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 }}
# Allowlist: only build preview images when an input that actually
# reaches one of the images changes. Each entry is consumed by a
# dockerfile COPY/build-arg or by the task build steps (gen-apis /
# build:binary). Anything not listed (docs, tests, lint config, dev
# taskfiles, other workflows) cannot change an image. Commercial code
# from 8gcr isn't applied for PR previews at all, see publish-images.yml.
IMAGE_INPUTS: |
^src/
^api/v2\.0/swagger\.yaml$
^tools/swagger/templates/
^make/migrations/
^icons/
^config/portal/
^LICENSE$
^VERSION$
^dockerfile/
^versions\.env$
^Taskfile\.yml$
^taskfile/build\.yml$
^\.github/workflows/pr-ci\.yml$
^\.github/actions/setup-go-cached/
^\.github/actions/setup-task/
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 an image input went away
changed="$(git diff --name-only --no-renames "${base}...${HEAD_SHA}")"
if matched="$(grep -Ef <(printf '%s' "${IMAGE_INPUTS}") <<<"${changed}")"; then
echo "build=true" >> "$GITHUB_OUTPUT"
# Named in the PR comment, so a PR that changes no image 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 image input changed between ${base} and ${HEAD_SHA}; skipping the preview images"
echo "build=false" >> "$GITHUB_OUTPUT"
fi
build:
name: "${{ matrix.component }} (${{ matrix.platform }})"
needs: [changes]
if: needs.changes.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
component: [core, jobservice, registryctl, exporter, portal, registry, trivy-adapter]
platform:
- linux/amd64
- linux/arm64
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: Prepare
id: prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
if [ "${{ matrix.component }}" = "trivy-adapter" ]; then
echo "image_name=trivy-adapter" >> "$GITHUB_OUTPUT"
else
echo "image_name=harbor-${{ matrix.component }}" >> "$GITHUB_OUTPUT"
fi
# Surface pod env var for GitHub Actions expressions
if [ -n "$BUILDX_HOST" ]; then
echo "BUILDX_HOST=$BUILDX_HOST" >> "$GITHUB_ENV"
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load versions
run: grep -E '^[A-Z_]+=' versions.env >> "$GITHUB_ENV"
- name: Setup Go
uses: $/.github/actions/setup-go-cached
with:
go-version-file: src/go.mod
go-sum-path: src/go.sum
- name: Install Task
uses: $/.github/actions/setup-task
with:
version: 3.49.1
- name: Generate API code
if: ${{ contains(fromJSON('["core","jobservice","registryctl","exporter","registry","trivy-adapter"]'), matrix.component) }}
run: task build:gen-apis
- name: Compile Go binary
if: ${{ contains(fromJSON('["core","jobservice","registryctl","exporter","registry","trivy-adapter"]'), matrix.component) }}
env:
COMPONENT: ${{ matrix.component }}
PLATFORM_PAIR: ${{ env.PLATFORM_PAIR }}
run: task build:binary:${COMPONENT}:${PLATFORM_PAIR} BASE_GIT_COMMIT="${GITHUB_SHA:0:8}"
- name: Compile image helper binaries
env:
COMPONENT: ${{ matrix.component }}
PLATFORM_PAIR: ${{ env.PLATFORM_PAIR }}
run: |
task build:binary:lprobe:${PLATFORM_PAIR}
if [ "${COMPONENT}" = "trivy-adapter" ]; then
task build:binary:trivy:${PLATFORM_PAIR}
fi
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
if: env.BUILDX_HOST == ''
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: ${{ env.BUILDX_HOST && 'remote' || '' }}
endpoint: ${{ env.BUILDX_HOST }}
- 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 and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
# Build traces can contain signed storage URLs.
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
file: dockerfile/${{ matrix.component }}.dockerfile
platforms: ${{ matrix.platform }}
build-args: |
ALPINE_VERSION=${{ env.ALPINE_VERSION }}
NGINX_VERSION=${{ env.NGINX_VERSION }}
BUN_VERSION=${{ env.BUN_VERSION }}
GO_VERSION=${{ env.GO_VERSION }}
DISTRIBUTION_VERSION=${{ env.DISTRIBUTION_VERSION }}
LPROBE_VERSION=${{ env.LPROBE_VERSION }}
TRIVY_BASE_IMAGE_VERSION=${{ env.TRIVY_BASE_IMAGE_VERSION }}
HARBOR_SCANNER_TRIVY_VERSION=${{ env.HARBOR_SCANNER_TRIVY_VERSION }}
tags: ${{ env.REGISTRY_ADDRESS }}/${{ env.PR_REGISTRY_PROJECT }}/${{ steps.prepare.outputs.image_name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview-digest-${{ matrix.component }}-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: "Merge ${{ matrix.component }}"
needs: [changes, build]
if: needs.changes.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
component: [core, jobservice, registryctl, exporter, portal, registry, trivy-adapter]
runs-on: ubuntu-26.04
permissions:
contents: read
steps:
- name: Prepare BuildKit
id: prepare
run: |
if [ "${{ matrix.component }}" = "trivy-adapter" ]; then
echo "image_name=trivy-adapter" >> "$GITHUB_OUTPUT"
else
echo "image_name=harbor-${{ matrix.component }}" >> "$GITHUB_OUTPUT"
fi
if [ -n "$BUILDX_HOST" ]; then
echo "BUILDX_HOST=$BUILDX_HOST" >> "$GITHUB_ENV"
fi
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ${{ runner.temp }}/digests
pattern: preview-digest-${{ matrix.component }}-*
merge-multiple: true
- 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 }}
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
with:
driver: ${{ env.BUILDX_HOST && 'remote' || '' }}
endpoint: ${{ env.BUILDX_HOST }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ env.REGISTRY_ADDRESS }}/${{ env.PR_REGISTRY_PROJECT }}/${{ steps.prepare.outputs.image_name }}
run: |
# shellcheck disable=SC2046
docker buildx imagetools create \
-t "${IMAGE}:${PREVIEW_TAG}" \
$(printf "${IMAGE}@sha256:%s " *)
sign:
name: Sign preview images
needs: [changes, merge]
if: needs.changes.outputs.build == 'true'
runs-on: ubuntu-26.04
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Go
uses: $/.github/actions/setup-go-cached
with:
go-version-file: src/go.mod
go-sum-path: src/go.sum
- name: Install crane
run: go install github.com/google/go-containerregistry/cmd/crane@v0.20.3
- name: Install envsubst
run: command -v envsubst || (sudo apt-get update && sudo apt-get install -y gettext-base)
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Install syft
uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
- 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: Sign images, generate SBOMs, and attest
run: |
for image in core jobservice registryctl exporter portal registry trivy-adapter; do
image_name="harbor-${image}"
if [ "${image}" = "trivy-adapter" ]; then
image_name="trivy-adapter"
fi
ref="${REGISTRY_ADDRESS}/${PR_REGISTRY_PROJECT}/${image_name}:${PREVIEW_TAG}"
digest="$(crane digest "${ref}")"
image_ref="${REGISTRY_ADDRESS}/${PR_REGISTRY_PROJECT}/${image_name}@${digest}"
cosign sign --yes "${image_ref}"
syft "${image_ref}" -o spdx-json > "sbom-${image}.spdx.json"
cosign attest --yes --predicate "sbom-${image}.spdx.json" --type spdxjson "${image_ref}"
done
pr-comment:
name: Comment preview image refs
needs: [changes, sign]
# 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_TAG: pr-${{ github.event.pull_request.number }}
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.sign.result }}
steps:
- name: Create or update PR comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MARKER: "<!-- harbor-next-pr-preview -->"
SIBLING_MARKER: "<!-- harbor-next-pr-chart-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 registryAddress = process.env.REGISTRY_ADDRESS;
const registryProject = process.env.PR_REGISTRY_PROJECT;
const previewTag = process.env.PREVIEW_TAG;
const components = ['harbor-core', 'harbor-jobservice', 'harbor-registryctl', 'harbor-exporter', 'harbor-portal', 'harbor-registry', 'trivy-adapter'];
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 image 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 image input check for \`${short}\` did not complete (${process.env.CHANGES_RESULT})`;
} else if (process.env.CHANGES_BUILD !== 'true') {
reason = `no image 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 image 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 images come 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)
? `A preview chart for this PR has a comment, but it is currently marked outdated: ${sibling.html_url}`
: `A preview chart for this PR is published too, and its comment carries the install command: ${sibling.html_url}`]
: [];
const body = [
marker,
`Preview images for this PR are available in \`${registryAddress}/${registryProject}\` with tag \`${previewTag}\`, built from \`${short}\`:`,
'',
...components.map(name => `- \`${registryAddress}/${registryProject}/${name}:${previewTag}\``),
...stackSection,
...siblingSection,
'',
'Verify a preview image:',
'',
'```',
'cosign verify \\',
` --certificate-identity-regexp="https://github.com/${repository}/.github/workflows/pr-ci.yml@.*" \\`,
' --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \\',
` ${registryAddress}/${registryProject}/harbor-core:${previewTag}`,
'```',
'',
'Verify the SBOM attestation:',
'',
'```',
'cosign verify-attestation \\',
` --certificate-identity-regexp="https://github.com/${repository}/.github/workflows/pr-ci.yml@.*" \\`,
' --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \\',
' --type spdxjson \\',
` ${registryAddress}/${registryProject}/harbor-core:${previewTag}`,
'```',
].join('\n');
await post(body);