Register preview build #138
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: Register preview build | |
| # PUBLISHING WORKFLOW. Takes the artifact built by "Publish preview build", | |
| # publishes it to the registry bridge, and comments on the PR. It is the only | |
| # place a bridge credential exists. | |
| # | |
| # Why it is separate: GitHub denies fork pull_request runs both secrets and | |
| # `id-token: write`, so a fork PR cannot authenticate to the bridge from the | |
| # build workflow. A workflow_run workflow runs the file from the DEFAULT BRANCH | |
| # in base-repo context, so it can mint an OIDC token wherever the PR came from. | |
| # There is no variant of this that keeps the publish in the build workflow. | |
| # See rfcs/0002-zero-trust-github-oidc-publishing.md in | |
| # voidzero-dev/pkg-pr-registry-bridge. | |
| # | |
| # What is NOT a boundary: the `preview-build` label check in the build workflow. | |
| # On pull_request events GitHub runs the workflow file from the merge ref, so a | |
| # PR author can edit that file to delete the check, or add another workflow with | |
| # a matching `name:` to trigger this one. | |
| # | |
| # What IS load-bearing, and what a reviewer must not remove: | |
| # - `authorize` re-derives authorization from the API and fails closed, which | |
| # is what makes the label mean anything | |
| # - nothing from the triggering run is trusted except the fields GitHub signs | |
| # into the event payload (head_sha, conclusion, event, path) | |
| # - the artifact is treated as hostile bytes, and its download pins run-id to | |
| # the triggering run | |
| # - no job that installs or executes preview content holds id-token | |
| # - `publish` of a fork PR is gated on an environment with required | |
| # reviewers; a same-repo PR publishes without approval because the | |
| # maintainer-applied label already binds consent to the exact built sha | |
| # | |
| # zizmor's dangerous-triggers audit warns that workflow_run is "almost always | |
| # used insecurely". The danger it names is real here and the trigger is | |
| # unavoidable for the reason above, so it is suppressed inline, next to the | |
| # controls that answer it. | |
| on: # zizmor: ignore[dangerous-triggers] | |
| workflow_run: | |
| # Matched by workflow NAME, so renaming the build workflow silently stops | |
| # publishing. The `workflow_run.path` check in `authorize` is the durable | |
| # identity; this filter only avoids queueing a skipped run per workflow. | |
| workflows: ['Publish preview build'] | |
| types: [completed] | |
| permissions: {} | |
| env: | |
| IMAGE: ghcr.io/voidzero-dev/vite-plus | |
| concurrency: | |
| # Keyed on the built commit, so a re-label or re-run coalesces. | |
| group: register-preview-${{ github.event.workflow_run.head_sha }} | |
| # NOT cancel-in-progress. Cancelling a publish part-way is what left the | |
| # bridge with a packument/tarball mismatch on 2026-07-02; the action registers | |
| # the ref last precisely so an interrupted run stays invisible, and cancelling | |
| # mid-upload throws that away. | |
| cancel-in-progress: false | |
| jobs: | |
| # SR-1. Re-derive authorization from repository state rather than from | |
| # anything the triggering run produced. Fails closed: no PR, no label, a | |
| # closed PR, or an API error all stop the publish. | |
| authorize: | |
| name: Authorize | |
| if: >- | |
| github.repository == 'voidzero-dev/vite-plus' && | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.path == '.github/workflows/publish-preview.yml' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| actions: read # list the triggering run's artifacts | |
| outputs: | |
| eligible: ${{ steps.check.outputs.eligible }} | |
| pr: ${{ steps.check.outputs.pr }} | |
| pr-url: ${{ steps.check.outputs.pr_url }} | |
| is-fork: ${{ steps.check.outputs.is_fork }} | |
| head-repo: ${{ steps.check.outputs.head_repo }} | |
| installer-x64-id: ${{ steps.check.outputs.installer_x64_id }} | |
| installer-arm64-id: ${{ steps.check.outputs.installer_arm64_id }} | |
| steps: | |
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| id: check | |
| env: | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} | |
| HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| with: | |
| script: | | |
| const headSha = process.env.HEAD_SHA; | |
| const { owner, repo } = context.repo; | |
| // Resolve the PR by its HEAD BRANCH, not by commit association. | |
| // | |
| // This used listPullRequestsAssociatedWithCommit, which returns | |
| // EMPTY for a fork PR's head commit -- the exact case this workflow | |
| // exists for -- while working correctly for same-repo PRs. So it | |
| // looked right until the first fork PR arrived (#2391) and failed | |
| // with "no open PR has head <sha>" for a PR that was open with | |
| // precisely that head. `workflow_run.pull_requests` is also empty | |
| // for forks, which is what sent me to the commit endpoint in the | |
| // first place; both are fork-blind. | |
| // | |
| // head_repository.owner.login and head_branch are GitHub-signed | |
| // payload fields, so this stays as trustworthy as the sha. | |
| const headOwner = process.env.HEAD_OWNER; | |
| const headBranch = process.env.HEAD_BRANCH; | |
| if (!headOwner || !headBranch) { | |
| core.setFailed('workflow_run payload has no head repository or branch'); | |
| return; | |
| } | |
| const pulls = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| head: `${headOwner}:${headBranch}`, | |
| }); | |
| const forBranch = pulls.filter( | |
| (p) => p.state === 'open' && p.base.repo.full_name === `${owner}/${repo}`, | |
| ); | |
| if (forBranch.length === 0) { | |
| core.setFailed( | |
| `no open PR of ${owner}/${repo} from ${headOwner}:${headBranch}; refusing to publish`, | |
| ); | |
| return; | |
| } | |
| // Separated from the branch lookup so the message says which of the | |
| // two happened: no such PR, or the PR moved on since this build. | |
| const candidates = forBranch.filter((p) => p.head.sha === headSha); | |
| if (candidates.length === 0) { | |
| core.setFailed( | |
| `PR ${forBranch.map((p) => `#${p.number}`).join(', ')} for ` + | |
| `${headOwner}:${headBranch} now points at ` + | |
| `${forBranch[0].head.sha.slice(0, 7)}, but this run built ` + | |
| `${headSha.slice(0, 7)}; re-apply the label to build the current head`, | |
| ); | |
| return; | |
| } | |
| // More than one open PR can share a head commit (the same branch | |
| // opened against two bases, for instance). The workflow_run payload | |
| // does not say which PR caused the run, and `pull_requests` is empty | |
| // for forks, so there is no way to pick the right one. Taking the | |
| // first would let an UNLABELED PR's run borrow a labeled PR's | |
| // authorization and publish under that PR's tag, so refuse instead. | |
| if (candidates.length > 1) { | |
| core.setFailed( | |
| `head ${headSha} is the head of ${candidates.length} open PRs (` + | |
| candidates.map((p) => `#${p.number}`).join(', ') + | |
| '); cannot tell which triggered this run, refusing to publish', | |
| ); | |
| return; | |
| } | |
| const pr = candidates[0]; | |
| // The label is applied by a maintainer and cannot be set from a | |
| // fork, which is what makes it the consent step. Read it fresh | |
| // here rather than trusting the build workflow's own check. | |
| const labels = pr.labels.map((l) => l.name); | |
| if (!labels.includes('preview-build')) { | |
| core.setFailed( | |
| `PR #${pr.number} is not labeled preview-build; refusing to publish`, | |
| ); | |
| return; | |
| } | |
| // The build workflow triggers on EVERY `labeled` event but its jobs | |
| // run for `preview-build`, so an unrelated label produces a run in | |
| // which everything skips — and an all-skipped run still concludes | |
| // "success". The PR legitimately still carries the label, so every | |
| // check above passes and a run that built nothing would reach the | |
| // publish job: queued for approval on a fork, straight to a failed | |
| // download on a same-repo PR. Requiring the artifact is what | |
| // separates "built something" from "did nothing", and it also | |
| // catches a build workflow that succeeded without uploading. | |
| // | |
| // Not a failure: adding an unrelated label to a labeled PR is a | |
| // normal thing to do, and a red X on every one of them would be | |
| // noise. Skip quietly instead. | |
| const artifacts = await github.paginate( | |
| github.rest.actions.listWorkflowRunArtifacts, | |
| { owner, repo, run_id: Number(process.env.RUN_ID) }, | |
| ); | |
| if (!artifacts.some((a) => a.name === 'bridge-packages' && !a.expired)) { | |
| core.info( | |
| `run ${process.env.RUN_ID} produced no bridge-packages artifact; nothing to publish`, | |
| ); | |
| core.setOutput('eligible', 'false'); | |
| return; | |
| } | |
| const installerTargets = { | |
| installer_x64_id: 'vp-setup-x86_64-pc-windows-msvc', | |
| installer_arm64_id: 'vp-setup-aarch64-pc-windows-msvc', | |
| }; | |
| for (const [output, name] of Object.entries(installerTargets)) { | |
| const artifact = artifacts.find((a) => a.name === name && !a.expired); | |
| if (!artifact) { | |
| core.setFailed(`run ${process.env.RUN_ID} has no active ${name} artifact`); | |
| return; | |
| } | |
| core.setOutput(output, String(artifact.id)); | |
| } | |
| const headRepo = pr.head.repo?.full_name ?? '(deleted fork)'; | |
| const isFork = headRepo !== `${owner}/${repo}`; | |
| core.setOutput('eligible', 'true'); | |
| core.setOutput('pr', String(pr.number)); | |
| core.setOutput('pr_url', pr.html_url); | |
| core.setOutput('is_fork', String(isFork)); | |
| core.setOutput('head_repo', headRepo); | |
| core.info(`authorized PR #${pr.number} from ${headRepo} (fork: ${isFork})`); | |
| publish: | |
| name: Pkg Preview | |
| needs: authorize | |
| if: needs.authorize.outputs.eligible == 'true' | |
| runs-on: ubuntu-latest | |
| # Required-reviewer gate on the ONE job that mints a publish credential, | |
| # applied to FORK PRs only. The `authorize` job proves the PR is open and | |
| # labeled; for a fork, the gate proves a human looked at this specific run | |
| # before a token existed, and records who. It is the only defense here that | |
| # does not depend on my own logic being right: if `authorize` were ever | |
| # weakened, it still stops an unattended publish of unreviewed external | |
| # code. | |
| # | |
| # Same-repo PRs skip the gate. The label is already a deliberate maintainer | |
| # action, the build runs only on `labeled` events, and `authorize` plus the | |
| # re-check below pin the publish to the head sha the label was applied to, | |
| # so approving a self-labeled run confirmed nothing the label had not. The | |
| # approval wait was also easy to miss: reviewer notifications follow each | |
| # reviewer's Actions notification settings. | |
| # | |
| # Self-review is allowed on the fork gate: requiring a SECOND person for | |
| # every external contributor's preview would cost more than the risk | |
| # warrants. The value is the deliberate confirmation and the audit trail, | |
| # not two-person control. | |
| # | |
| # BOTH environments must exist: preview-build-release WITH required | |
| # reviewers, preview-build-release-auto with NO protection rules. A | |
| # workflow referencing a MISSING environment gets one created implicitly | |
| # with no rules, which looks like a gate and is not one. | |
| environment: ${{ needs.authorize.outputs.is-fork == 'true' && 'preview-build-release' || 'preview-build-release-auto' }} | |
| # SR-5: this job mints the publish token, so it must never run anything out | |
| # of the artifact. It downloads bytes and hands them to the bridge action; | |
| # no install, no build, no scripts. | |
| permissions: | |
| id-token: write # mint the bridge OIDC token | |
| actions: read # download the triggering run's artifact | |
| pull-requests: read # re-check the PR before a token exists | |
| outputs: | |
| version: ${{ steps.bridge.outputs.version }} | |
| steps: | |
| # `authorize` ran BEFORE this job, so its verdict is a snapshot. On a | |
| # fork run that waits for approval the snapshot can be days old (the | |
| # artifact is retained 7 days precisely to allow that); on a same-repo | |
| # run the window is seconds, but the check is cheap, so it runs for both. | |
| # Re-assert the verdict here, before a token exists: the PR can have been | |
| # closed, had the label removed (which is how a maintainer revokes | |
| # consent), or advanced to a new head commit, in which case publishing | |
| # this artifact would move the pr-<n> dist-tag BACKWARDS onto an older | |
| # commit than the PR now points at. | |
| - name: Re-check authorization before publish | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| env: | |
| PR_NUMBER: ${{ needs.authorize.outputs.pr }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, | |
| repo, | |
| pull_number: Number(process.env.PR_NUMBER), | |
| }); | |
| const reasons = []; | |
| if (pr.state !== 'open') reasons.push(`PR is ${pr.state}`); | |
| if (!pr.labels.some((l) => l.name === 'preview-build')) { | |
| reasons.push('preview-build label was removed'); | |
| } | |
| if (pr.head.sha !== process.env.HEAD_SHA) { | |
| reasons.push( | |
| `PR head moved to ${pr.head.sha.slice(0, 7)}, this build is ${process.env.HEAD_SHA.slice(0, 7)}`, | |
| ); | |
| } | |
| if (reasons.length) { | |
| core.setFailed( | |
| `PR #${pr.number} changed between authorization and publish (${reasons.join('; ')}); ` + | |
| 'refusing to publish. Re-apply the label to build the current head.', | |
| ); | |
| } | |
| - name: Download packed packages | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: bridge-packages | |
| path: bridge-packages | |
| # Pinned to the run that triggered us. Resolving "latest artifact by | |
| # name" instead would let any later run swap the bytes. | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ github.token }} | |
| # Validates every archive against the canonical policy, rebuilds each one | |
| # under the commit version, uploads, and registers the ref last so the | |
| # version flips visible atomically. Authenticates with an OIDC token | |
| # minted here; no admin-token, so no bridge secret lives in this repo. | |
| - name: Publish to the registry bridge | |
| id: bridge | |
| uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main | |
| with: | |
| mode: upload | |
| # Trusted: GitHub signs this into the event payload. The published | |
| # version derives from it, never from the artifact's own manifest. | |
| sha: ${{ github.event.workflow_run.head_sha }} | |
| input-dir: bridge-packages | |
| # SR-2: derived from the API in `authorize`, never from the artifact. | |
| # The bridge maps this to the pr-<n> dist-tag, which VP_PR_VERSION | |
| # resolves, so an attacker-supplied value would change what installs | |
| # for a different PR. | |
| pr-url: ${{ needs.authorize.outputs.pr-url }} | |
| # Sticky comment with the resolved versions and per-package-manager registry | |
| # config. Separate job: it needs write permissions, and SR-5 keeps those away | |
| # from the job holding id-token. | |
| comment: | |
| name: Comment bridge version | |
| needs: [authorize, publish] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: write | |
| # issues:write as well as pull-requests:write, because createComment is | |
| # the issues endpoint and the create path runs on a PR that has no comment | |
| # yet. Kept deliberately: comment-docker-preview below gets away with only | |
| # pull-requests:write because it always runs after this one. | |
| issues: write | |
| steps: | |
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| env: | |
| BRIDGE_VERSION: ${{ needs.publish.outputs.version }} | |
| PR_NUMBER: ${{ needs.authorize.outputs.pr }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| IS_FORK: ${{ needs.authorize.outputs.is-fork }} | |
| HEAD_REPO: ${{ needs.authorize.outputs.head-repo }} | |
| RUN_URL: ${{ github.event.workflow_run.html_url }} | |
| RUN_ID: ${{ github.event.workflow_run.id }} | |
| INSTALLER_X64_ID: ${{ needs.authorize.outputs.installer-x64-id }} | |
| INSTALLER_ARM64_ID: ${{ needs.authorize.outputs.installer-arm64-id }} | |
| with: | |
| script: | | |
| const version = process.env.BRIDGE_VERSION; | |
| const pr = Number(process.env.PR_NUMBER); | |
| const shortSha = process.env.HEAD_SHA.slice(0, 7); | |
| const isFork = process.env.IS_FORK === 'true'; | |
| const marker = '<!-- pkg-pr-bridge-version -->'; | |
| const bridge = 'https://registry-bridge.viteplus.dev/'; | |
| const installerScripts = | |
| `https://raw.githubusercontent.com/${process.env.HEAD_REPO}/${process.env.HEAD_SHA}/packages/cli`; | |
| const artifactUrl = (id) => | |
| `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.RUN_ID}/artifacts/${id}`; | |
| // Built as a line array (not a template literal) so the inline | |
| // backticks and the fenced json block don't need backslash-escaping. | |
| const body = [ | |
| marker, | |
| '', | |
| `### Registry bridge build (\`${shortSha}\`)`, | |
| '', | |
| // Fork builds carry unreviewed third-party code. The install | |
| // lines below are official-looking, so say plainly where the | |
| // code came from before anyone pipes it into a shell. | |
| ...(isFork | |
| ? [ | |
| `> [!WARNING]`, | |
| `> This build is from the fork \`${process.env.HEAD_REPO}\` and has **not** been reviewed.`, | |
| `> Installing it runs that code on your machine. [Build log](${process.env.RUN_URL})`, | |
| '', | |
| ] | |
| : []), | |
| 'This commit build is published to the [registry bridge](https://github.com/voidzero-dev/pkg-pr-registry-bridge), which serves these as ordinary npm versions (every other package proxies to npmjs):', | |
| '', | |
| '| Package | Version |', | |
| '| --- | --- |', | |
| `| \`vite-plus\` | \`${version}\` |`, | |
| `| \`@voidzero-dev/vite-plus-core\` | \`${version}\` |`, | |
| '', | |
| '**Install the Vite+ CLI built from this commit, then migrate a project:**', | |
| '', | |
| '```bash', | |
| '# macOS / Linux', | |
| `curl -fsSL ${installerScripts}/install.sh | VP_PR_VERSION=${pr} bash`, | |
| '```', | |
| '```powershell', | |
| '# Windows (PowerShell)', | |
| `$env:VP_PR_VERSION="${pr}"; irm ${installerScripts}/install.ps1 | iex`, | |
| '```', | |
| '', | |
| '**Or download the standalone Windows installer built from this commit:**', | |
| '', | |
| '| Architecture | Installer |', | |
| '| --- | --- |', | |
| `| x64 | [\`vp-setup-x86_64-pc-windows-msvc.exe\`](${artifactUrl(process.env.INSTALLER_X64_ID)}) |`, | |
| `| Arm64 | [\`vp-setup-aarch64-pc-windows-msvc.exe\`](${artifactUrl(process.env.INSTALLER_ARM64_ID)}) |`, | |
| '', | |
| 'GitHub requires you to sign in and downloads each installer as a ZIP artifact. Extract `vp-setup.exe`, then run it against this preview build:', | |
| '', | |
| '```powershell', | |
| `.\\vp-setup.exe --version "${version}" --registry "${bridge}"`, | |
| '```', | |
| '', | |
| `After installing, upgrade the current project's \`vite-plus\` to this test build with:`, | |
| '', | |
| '```bash', | |
| 'vp migrate', | |
| '```', | |
| '', | |
| `**Or** point your package manager at the bridge registry \`${bridge}\`:`, | |
| '', | |
| '| Package manager | Registry config |', | |
| '| --- | --- |', | |
| `| npm / pnpm / Bun | \`.npmrc\`: \`registry=${bridge}\` |`, | |
| `| Yarn (v2+) | \`.yarnrc.yml\`: \`npmRegistryServer: "${bridge}"\` |`, | |
| '', | |
| 'Then pin the build (`vite` aliases to vite-plus-core; pnpm can use a catalog, npm an `overrides` entry):', | |
| '', | |
| '```json', | |
| '{', | |
| ' "devDependencies": {', | |
| ` "vite-plus": "${version}",`, | |
| ` "vite": "npm:@voidzero-dev/vite-plus-core@${version}"`, | |
| ' }', | |
| '}', | |
| '```', | |
| ].join('\n'); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr, | |
| }); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr, | |
| body, | |
| }); | |
| } | |
| # Build and push a preview Docker image from the registry bridge build so the | |
| # image can be verified before a real release. Tagged `pr-<number>`; never | |
| # `latest`. See docker/Dockerfile and docs/guide/docker.md. | |
| # | |
| # Same-repo PRs only, for now. This job installs the preview package, which | |
| # executes the PR's code, and pushes the result to the org's GHCR namespace as | |
| # ghcr.io/voidzero-dev/vite-plus:pr-<n>. Before the split a fork PR did reach | |
| # this job and failed at the push, because fork runs get a read-only | |
| # GITHUB_TOKEN; running in base-repo context is what would make it succeed. | |
| # Whether to publish unreviewed fork code under the org's name is a product | |
| # decision, not a technical blocker: drop the `is-fork` condition to enable it. | |
| publish-docker-preview: | |
| name: Docker preview image | |
| needs: [authorize, publish] | |
| if: needs.authorize.outputs.is-fork == 'false' | |
| runs-on: ubuntu-latest | |
| # No id-token here (SR-5): this job runs the preview package's install | |
| # scripts, so it must not be able to mint a bridge token. | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| # workflow_run jobs check out the DEFAULT BRANCH, so this is main's | |
| # Dockerfile, not the PR's. That is what we want: only the installed | |
| # package should come from the PR. | |
| - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Builds from this PR's registry bridge build (VP_PR_VERSION resolves it | |
| # through the bridge). | |
| # amd64-only: this throwaway preview avoids the slow arm64 QEMU leg; arm64 | |
| # is covered by the release build and the test-install-sh-arm64 job. | |
| - name: Build and push preview image | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: docker | |
| file: docker/Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| tags: ${{ env.IMAGE }}:pr-${{ needs.authorize.outputs.pr }} | |
| build-args: | | |
| VP_PR_VERSION=${{ needs.authorize.outputs.pr }} | |
| # Single-manifest image (no attestation index): simpler for consumers | |
| # and lets the comment job read the size via `docker manifest inspect`. | |
| provenance: false | |
| comment-docker-preview: | |
| name: Comment Docker preview | |
| needs: [authorize, publish-docker-preview] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| packages: read | |
| pull-requests: write | |
| env: | |
| PR_NUMBER: ${{ needs.authorize.outputs.pr }} | |
| steps: | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # Compressed download size from the registry manifest (config + layers), | |
| # without pulling the image. Matches what `docker pull` transfers and what | |
| # the GHCR package page shows. | |
| - name: Measure image size | |
| id: size | |
| run: | | |
| bytes=$(docker manifest inspect "${IMAGE}:pr-${PR_NUMBER}" | jq '[.config.size] + [.layers[].size] | add') | |
| echo "compressed=$(numfmt --to=si --suffix=B "$bytes")" >> "$GITHUB_OUTPUT" | |
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| env: | |
| COMPRESSED_SIZE: ${{ steps.size.outputs.compressed }} | |
| with: | |
| script: | | |
| const image = process.env.IMAGE; | |
| const pr = Number(process.env.PR_NUMBER); | |
| const marker = '<!-- docker-preview -->'; | |
| // Built as a line array (not a template literal) so the fenced code | |
| // blocks don't collide with the YAML block-scalar indentation. | |
| const body = [ | |
| marker, | |
| '## 🐳 Docker preview image', | |
| '', | |
| "Built from this PR's registry bridge build:", | |
| '', | |
| '| Image | Compressed size |', | |
| '| --- | --- |', | |
| `| \`${image}:pr-${pr}\` | ${process.env.COMPRESSED_SIZE} |`, | |
| '', | |
| '```bash', | |
| `# remove any stale local copy from a previous run, then pull fresh`, | |
| `docker rmi ${image}:pr-${pr} 2>/dev/null; docker pull ${image}:pr-${pr}`, | |
| '```', | |
| '', | |
| 'Quick check:', | |
| '', | |
| '```bash', | |
| `docker run --rm ${image}:pr-${pr} vp --version`, | |
| '```', | |
| '', | |
| 'See [docs/guide/docker.md](https://github.com/voidzero-dev/vite-plus/blob/main/docs/guide/docker.md) for usage.', | |
| ].join('\n'); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr, | |
| }); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr, | |
| body, | |
| }); | |
| } |