From e3ab0f7a3e451a24d3d8e54d88443dfe78453547 Mon Sep 17 00:00:00 2001 From: phorcys420 <57866459+phorcys420@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:45:26 +0000 Subject: [PATCH] ci(.github): publish release ISOs as artifacts, share the ISO table GitHub release assets are capped at 2 GiB, which the box ISOs now exceed, so tagged releases failed while uploading the images. Publish each ISO as a GitHub Actions artifact (90-day retention, the public-repo max) instead and carry a table linking every kind/arch in the release body. Extract the PR sticky-comment table renderer into a reusable iso-table composite action, reused verbatim by the release body. Fold ISO size recording into the build-iso action (new system input) so both workflows get the size metadata for free. Stopgap until the ISOs move to S3, at which point only the upload steps and the iso-table links change. --- .github/actions/build-iso/action.yml | 21 ++++ .github/actions/iso-table/action.yml | 150 +++++++++++++++++++++++++++ .github/workflows/release.yml | 58 ++++++++--- .github/workflows/test.yml | 123 ++++------------------ 4 files changed, 236 insertions(+), 116 deletions(-) create mode 100644 .github/actions/iso-table/action.yml diff --git a/.github/actions/build-iso/action.yml b/.github/actions/build-iso/action.yml index f6b0e3c..0daa423 100644 --- a/.github/actions/build-iso/action.yml +++ b/.github/actions/build-iso/action.yml @@ -55,6 +55,14 @@ inputs: one place (the test workflow builds installer + appliance side by side). required: false default: "" + system: + description: >- + Runner architecture (e.g. "x86_64-linux"). When set on a full build, record + each built ISO's exact byte size to iso-sizes--.tsv in the + dist dir (one "system\tfile\tbytes" row per ISO) so the iso-table action + can render sizes + download links. Empty skips size recording. + required: false + default: "" outputs: dist: @@ -73,6 +81,7 @@ runs: TARGET: ${{ inputs.target }} FULL: ${{ inputs.full }} DIST: ${{ inputs.dist }} + SYSTEM: ${{ inputs.system }} # Read by the Makefile under --impure for the image's pretty version # name / boot-screen label. Set through env (not inlined into the script) # so an arbitrary PR title can't break the shell; empty on non-PR events. @@ -89,6 +98,18 @@ runs: if [ "$FULL" = "true" ]; then make "$TARGET/iso" cp -L "out/$TARGET-iso/iso"/* "$dist/" + # Record this kind's ISO size(s) for the iso-table renderer. Named per + # target+system so parallel builds sharing a dist (or merged from + # separate jobs) never clash. Only the ISO(s) just built are stated, + # not everything already staged in dist. + if [ -n "$SYSTEM" ]; then + meta="$dist/iso-sizes-$TARGET-$SYSTEM.tsv" + : >"$meta" + for iso in "out/$TARGET-iso/iso"/*.iso; do + [ -e "$iso" ] || continue + printf '%s\t%s\t%s\n' "$SYSTEM" "$(basename "$iso")" "$(stat -c %s "$iso")" >>"$meta" + done + fi else make "$TARGET/drv" fi diff --git a/.github/actions/iso-table/action.yml b/.github/actions/iso-table/action.yml new file mode 100644 index 0000000..050b38d --- /dev/null +++ b/.github/actions/iso-table/action.yml @@ -0,0 +1,150 @@ +# Render the ISO build table (kind × arch: exact size + download link) shared by +# the test and release workflows. +# +# The build-iso action writes one `iso-sizes--.tsv` per built +# ISO; each workflow uploads those as `iso-meta-*` artifacts and downloads them +# back into a single dir (merge-multiple) that this action reads. The table +# links each kind/arch to its ISO artifact's browser download URL, resolved from +# the current run's artifacts — the very artifacts the workflow just uploaded. +# (When box moves ISOs to S3 these links become S3 URLs; only this action and +# the upload steps change.) +# +# The rendered markdown is exposed as the `table` output so callers place it +# wherever they need: the test workflow sets `sticky-comment: true` to upsert a +# single PR comment; the release workflow feeds `table` into the release body. +name: Render ISO table +description: >- + Render the coder/box ISO build table (size + download link per kind × arch) + from per-arch size metadata, and optionally upsert it as a sticky PR comment. + +inputs: + meta-dir: + description: >- + Directory holding the per-arch `iso-sizes--.tsv` rows + (downloaded from the `iso-meta-*` artifacts, merged into one dir). + required: false + default: meta + expiry-days: + description: >- + Artifact retention in days, shown in the table footer so readers know how + long the download links stay live. + required: false + default: "1" + sticky-comment: + description: >- + "true" upserts the table as a sticky pull-request comment (matched by a + hidden marker so every rebuild updates the same comment). "false" only + renders the `table` output. + required: false + default: "false" + github-token: + description: Token used to list run artifacts and (when sticky) upsert the PR comment. + required: false + default: ${{ github.token }} + +outputs: + table: + description: The rendered markdown table (without the sticky-comment marker). + value: ${{ steps.render.outputs.table }} + +runs: + using: composite + steps: + - name: Render ISO table + id: render + uses: actions/github-script@v7 + env: + META_DIR: ${{ inputs.meta-dir }} + EXPIRY_DAYS: ${{ inputs.expiry-days }} + STICKY_COMMENT: ${{ inputs.sticky-comment }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const path = require('path'); + + const metaDir = process.env.META_DIR || 'meta'; + const expiryDays = process.env.EXPIRY_DAYS || '1'; + const sticky = process.env.STICKY_COMMENT === 'true'; + + // 1. Collect exact ISO sizes from the per-arch TSV rows. + const rows = []; + const files = fs.existsSync(metaDir) ? fs.readdirSync(metaDir) : []; + for (const f of files) { + if (!f.startsWith('iso-sizes-') || !f.endsWith('.tsv')) continue; + const text = fs.readFileSync(path.join(metaDir, f), 'utf8'); + for (const line of text.split('\n')) { + if (!line.trim()) continue; + const [system, file, bytes] = line.split('\t'); + rows.push({ system, file, bytes: Number(bytes) }); + } + } + + // 2. Map each ISO artifact name -> its browser download URL. + const { owner, repo } = context.repo; + const runId = context.runId; + const arts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { owner, repo, run_id: runId, per_page: 100 }, + ); + const urlFor = (name) => { + const a = arts.find((x) => x.name === name); + return a + ? `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${a.id}` + : null; + }; + + // Small presentation helpers. + const fmtSize = (b) => { + if (!Number.isFinite(b) || b <= 0) return '—'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, n = b; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i += 1; } + return `${n.toFixed(i >= 2 ? 2 : 0)} ${u[i]}`; + }; + const prettyArch = (s) => s.replace(/-linux$/, ''); + const kindOf = (file) => file.includes('-installer-') + ? { slug: 'installer', label: 'Installer' } + : { slug: 'appliance', label: 'Appliance' }; + + // 3. Build the table, sorted by kind (installer before appliance) then + // arch for a stable layout. + const kindRank = (file) => (file.includes('-installer-') ? 0 : 1); + rows.sort((a, b) => + kindRank(a.file) - kindRank(b.file) || a.system.localeCompare(b.system)); + let body = '## 📀 ISO build artifacts\n\n'; + if (rows.length === 0) { + body += '_No ISO artifacts were produced in this run._\n'; + } else { + body += '| Kind | Arch | Size | Download |\n|:--|:--|--:|:--:|\n'; + for (const r of rows) { + const k = kindOf(r.file); + const url = urlFor(`coder-box-${k.slug}-${r.system}`); + const dl = url ? `[⬇️ \`${r.file}\`](${url})` : '—'; + body += `| ${k.label} | ${prettyArch(r.system)} | ${fmtSize(r.bytes)} | ${dl} |\n`; + } + } + const sha = (context.payload.pull_request?.head?.sha || context.sha).slice(0, 7); + const expiry = `artifacts expire in ~${expiryDays} day${expiryDays === '1' ? '' : 's'}`; + body += `\n↻ Updated for \`${sha}\` · ` + + `[run #${context.runNumber}](https://github.com/${owner}/${repo}/actions/runs/${runId}) · ` + + `${expiry} · sign in to GitHub to download.\n`; + + core.setOutput('table', body); + + // 4. When requested (and on a PR), upsert the sticky comment matched by + // this hidden marker instead of posting a new one each rebuild. + if (sticky && context.payload.pull_request) { + const MARKER = ''; + const commentBody = `${body}\n${MARKER}`; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(MARKER)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body: commentBody }); + } + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76fcb8a..d93cb68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,11 @@ # /nix/store cached across runs (nix-community/cache-nix-action). Bare # `make ` resolves to # the runner's native currentSystem; the resulting ISO + its .sha256 sidecar are -# dereferenced into a host mktemp dir. The release job gathers all ISOs and -# attaches them (with sha256 checksums) to the release. +# dereferenced into a host mktemp dir. Each ISO is published as a GitHub Actions +# artifact (not a release asset: assets are capped at 2 GiB and the ISOs exceed +# it) and the release body carries a table linking each kind/arch to its +# artifact. This is a stopgap until the ISOs move to S3, at which point only the +# upload steps and the iso-table action's links change. name: Build and release ISO @@ -93,19 +96,31 @@ jobs: uses: ./.github/actions/build-iso with: target: ${{ matrix.target }} + system: ${{ matrix.system }} + # Publish the ISO + its .sha256 sidecar as one artifact per kind (a single + # upload-artifact step uploads matched files CONCURRENTLY). Named + # coder-box-- so the iso-table renderer can resolve each + # kind/arch to its download URL. Kept for the max public-repo retention. - name: Upload ISO artifact uses: actions/upload-artifact@v6 with: name: coder-box-${{ matrix.target }}-${{ matrix.system }} - path: ${{ steps.build.outputs.dist }}/*.iso + path: | + ${{ steps.build.outputs.dist }}/*.iso + ${{ steps.build.outputs.dist }}/*.iso.sha256 + retention-days: 90 if-no-files-found: error - - name: Upload ISO checksum artifact + # Tiny size metadata (written by build-iso) the release job renders into + # the release-body table. Named per target+system so merge-multiple can't + # clash when both kinds share an arch. + - name: Upload ISO size metadata uses: actions/upload-artifact@v6 with: - name: coder-box-${{ matrix.target }}-${{ matrix.system }}-sha256 - path: ${{ steps.build.outputs.dist }}/*.iso.sha256 + name: iso-meta-${{ matrix.target }}-${{ matrix.system }} + path: ${{ steps.build.outputs.dist }}/iso-sizes-*.tsv + retention-days: 90 if-no-files-found: error release: @@ -114,7 +129,15 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: write + # listWorkflowRunArtifacts (download links) reads the run's artifacts. + actions: read steps: + # Checkout so the local iso-table composite action is available. + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.ref }} + - name: Determine release tag id: tag run: | @@ -126,23 +149,30 @@ jobs: echo "tag=$tag" >>"$GITHUB_OUTPUT" echo "Releasing tag: $tag" - - name: Download built ISOs + # Only the tiny size metadata is needed here; the multi-GB ISOs stay as + # artifacts and are linked from the release body, never re-downloaded. + - name: Download ISO size metadata uses: actions/download-artifact@v7 with: - path: dist + path: meta + pattern: iso-meta-* merge-multiple: true - - name: List release assets - run: ls -lhR dist/ + # Same renderer the test workflow uses for its sticky PR comment; here its + # output becomes the release body. 90-day expiry note matches the + # artifacts' retention. + - name: Render ISO table + id: table + uses: ./.github/actions/iso-table + with: + meta-dir: meta + expiry-days: "90" - name: Create / update GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.tag }} name: ${{ steps.tag.outputs.tag }} + body: ${{ steps.table.outputs.table }} generate_release_notes: true prerelease: ${{ github.event.inputs.prerelease || false }} - files: | - dist/*.iso - dist/*.iso.sha256 - fail_on_unmatched_files: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f3705c..72fe395 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -205,6 +205,7 @@ jobs: target: installer full: ${{ env.INSTALLER_FULL }} dist: ${{ steps.build.outputs.dist }} + system: ${{ matrix.system }} iso-compression: zstd -Xcompression-level 3 pr-title: ${{ github.event.pull_request.title }} pr-number: ${{ github.event.pull_request.number }} @@ -216,35 +217,22 @@ jobs: target: appliance full: ${{ env.APPLIANCE_FULL }} dist: ${{ steps.build.outputs.dist }} + system: ${{ matrix.system }} iso-compression: zstd -Xcompression-level 3 pr-title: ${{ github.event.pull_request.title }} pr-number: ${{ github.event.pull_request.number }} branch: ${{ github.head_ref }} - # Record the exact byte size of every ISO this arch actually built, one - # TSV row per ISO (system, filename, bytes). The iso-table job downloads - # these per-arch files and renders them into a single sticky PR comment. - # Skipped when nothing built full (drv-only runs produce no ISOs). - - name: Record ISO sizes - if: env.INSTALLER_FULL == 'true' || env.APPLIANCE_FULL == 'true' - run: | - dist="${{ steps.build.outputs.dist }}" - meta="$dist/iso-sizes-${{ matrix.system }}.tsv" - : >"$meta" - for f in "$dist"/*.iso; do - [ -e "$f" ] || continue - printf '%s\t%s\t%s\n' "${{ matrix.system }}" "$(basename "$f")" "$(stat -c %s "$f")" >>"$meta" - done - cat "$meta" - - # Per-arch ISO size metadata, kept tiny + short-lived. Named per system so - # the table job can merge both arches' files into one dir without clashing. + # Per-arch ISO size metadata (written by the build-iso action, one + # iso-sizes--.tsv per built kind), kept tiny + short-lived. + # The iso-table job merges every arch's files into one dir; the per-target + # filenames keep them from clashing. - name: Upload ISO size metadata if: env.INSTALLER_FULL == 'true' || env.APPLIANCE_FULL == 'true' uses: actions/upload-artifact@v6 with: name: iso-meta-${{ matrix.system }} - path: ${{ steps.build.outputs.dist }}/iso-sizes-${{ matrix.system }}.tsv + path: ${{ steps.build.outputs.dist }}/iso-sizes-*.tsv retention-days: 1 if-no-files-found: ignore @@ -291,9 +279,15 @@ jobs: runs-on: ubuntu-24.04 permissions: pull-requests: write + # listWorkflowRunArtifacts (download links) reads the run's artifacts. + actions: read steps: - # Pull both arches' per-arch size TSVs into one dir (filenames are - # system-suffixed, so merge-multiple can't clash). + # Checkout so the local iso-table composite action is available. + - name: Checkout + uses: actions/checkout@v5 + + # Pull every arch's per-target size TSVs into one dir (filenames are + # target+system-suffixed, so merge-multiple can't clash). - name: Download ISO size metadata uses: actions/download-artifact@v7 with: @@ -301,86 +295,11 @@ jobs: pattern: iso-meta-* merge-multiple: true + # Render + upsert the sticky PR comment through the shared action (the + # release workflow reuses the same renderer for its release body). - name: Upsert ISO artifact table comment - uses: actions/github-script@v7 + uses: ./.github/actions/iso-table with: - script: | - const fs = require('fs'); - const path = require('path'); - - // 1. Collect exact ISO sizes from the per-arch TSV rows. - const rows = []; - const dir = 'meta'; - const files = fs.existsSync(dir) ? fs.readdirSync(dir) : []; - for (const f of files) { - if (!f.startsWith('iso-sizes-') || !f.endsWith('.tsv')) continue; - const text = fs.readFileSync(path.join(dir, f), 'utf8'); - for (const line of text.split('\n')) { - if (!line.trim()) continue; - const [system, file, bytes] = line.split('\t'); - rows.push({ system, file, bytes: Number(bytes) }); - } - } - - // 2. Map each ISO artifact name -> its browser download URL. - const { owner, repo } = context.repo; - const runId = context.runId; - const arts = await github.paginate( - github.rest.actions.listWorkflowRunArtifacts, - { owner, repo, run_id: runId, per_page: 100 }, - ); - const urlFor = (name) => { - const a = arts.find((x) => x.name === name); - return a - ? `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${a.id}` - : null; - }; - - // Small presentation helpers. - const fmtSize = (b) => { - if (!Number.isFinite(b) || b <= 0) return '—'; - const u = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0, n = b; - while (n >= 1024 && i < u.length - 1) { n /= 1024; i += 1; } - return `${n.toFixed(i >= 2 ? 2 : 0)} ${u[i]}`; - }; - const prettyArch = (s) => s.replace(/-linux$/, ''); - const kindOf = (file) => file.includes('-installer-') - ? { slug: 'installer', label: 'Installer' } - : { slug: 'appliance', label: 'Appliance' }; - - // 3. Build the table, sorted by kind (installer before appliance) - // then arch for a stable layout. - const kindRank = (file) => (file.includes('-installer-') ? 0 : 1); - rows.sort((a, b) => - kindRank(a.file) - kindRank(b.file) || a.system.localeCompare(b.system)); - let body = '## 📀 ISO build artifacts\n\n'; - if (rows.length === 0) { - body += '_No ISO artifacts were produced in this run._\n'; - } else { - body += '| Kind | Arch | Size | Download |\n|:--|:--|--:|:--:|\n'; - for (const r of rows) { - const k = kindOf(r.file); - const url = urlFor(`coder-box-${k.slug}-${r.system}`); - const dl = url ? `[⬇️ \`${r.file}\`](${url})` : '—'; - body += `| ${k.label} | ${prettyArch(r.system)} | ${fmtSize(r.bytes)} | ${dl} |\n`; - } - } - const sha = (context.payload.pull_request?.head?.sha || context.sha).slice(0, 7); - body += `\n↻ Updated for \`${sha}\` · ` - + `[run #${context.runNumber}](https://github.com/${owner}/${repo}/actions/runs/${runId}) · ` - + `artifacts expire in ~1 day · sign in to GitHub to download.\n`; - - // 4. Upsert the sticky comment, matched by this hidden marker. - const MARKER = ''; - body += `\n${MARKER}`; - const issue_number = context.payload.pull_request.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number, per_page: 100, - }); - const existing = comments.find((c) => c.body && c.body.includes(MARKER)); - if (existing) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }); - } + meta-dir: meta + expiry-days: "1" + sticky-comment: "true"