fix(api): Reject query operands the column cannot take #1685
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 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); |