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"