diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index e47fa3b..c1c8088 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -9,10 +9,30 @@ on: type: string jobs: + validate-release-tag: + name: Validate release tag + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + + steps: + - name: Validate tag name + env: + TAG_NAME: ${{ inputs.tag_name }} + run: | + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $TAG_NAME" >&2 + exit 1 + fi + build-unix-binaries: name: Build ${{ matrix.os }} release assets + needs: + - validate-release-tag runs-on: ${{ matrix.os }} timeout-minutes: 30 + environment: release permissions: contents: write strategy: @@ -27,30 +47,42 @@ jobs: asset_arch: arm64 steps: + - name: Create release bot token + id: release-bot + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.PUTIO_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + ref: ${{ inputs.tag_name }} - - name: Check out release tag - run: git checkout ${{ inputs.tag_name }} - - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build SEA binary - run: vp run build:sea + run: pnpm run build:sea - name: Verify SEA binary - run: vp run verify:sea + run: pnpm run verify:sea - name: Package release assets shell: pwsh + env: + TAG_NAME: ${{ inputs.tag_name }} run: | - $version = "${{ inputs.tag_name }}".TrimStart("v") + $version = $env:TAG_NAME.TrimStart("v") $assetBase = "putio-cli-$version-${{ matrix.asset_os }}-${{ matrix.asset_arch }}" $releaseDir = ".artifacts/release" New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null @@ -70,44 +102,60 @@ jobs: run: Get-ChildItem .artifacts/release - name: Upload binary assets to the GitHub release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: + token: ${{ steps.release-bot.outputs.token }} tag_name: ${{ inputs.tag_name }} files: | .artifacts/release/* build-windows-binary: name: Build windows-latest release assets + needs: + - validate-release-tag runs-on: windows-latest timeout-minutes: 30 + environment: release permissions: contents: write steps: + - name: Create release bot token + id: release-bot + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.PUTIO_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + ref: ${{ inputs.tag_name }} - - name: Check out release tag - run: git checkout ${{ inputs.tag_name }} - - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build SEA binary - run: vp run build:sea + run: pnpm run build:sea - name: Verify SEA binary - run: vp run verify:sea + run: pnpm run verify:sea - name: Package release assets shell: pwsh + env: + TAG_NAME: ${{ inputs.tag_name }} run: | - $version = "${{ inputs.tag_name }}".TrimStart("v") + $version = $env:TAG_NAME.TrimStart("v") $assetBase = "putio-cli-$version-windows-amd64" $releaseDir = ".artifacts/release" New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null @@ -122,8 +170,9 @@ jobs: run: Get-ChildItem .artifacts/release - name: Upload binary assets to the GitHub release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: + token: ${{ steps.release-bot.outputs.token }} tag_name: ${{ inputs.tag_name }} files: | .artifacts/release/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 120daf1..d5be762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,21 +21,26 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Verify repository - run: vp run verify + run: pnpm run verify - name: Smoke test packed install surface - run: vp run smoke:pack + run: pnpm run smoke:pack release: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip ci]') @@ -44,6 +49,7 @@ jobs: - verify runs-on: ubuntu-latest timeout-minutes: 20 + environment: release permissions: contents: write issues: write @@ -54,23 +60,38 @@ jobs: new_release_version: ${{ steps.semantic.outputs.new_release_version }} steps: + - name: Create release bot token + id: release-bot + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.PUTIO_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write + permission-issues: write + permission-pull-requests: write - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + token: ${{ steps.release-bot.outputs.token }} - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build package - run: vp pack + run: pnpm run build - name: Release package id: semantic - uses: cycjimmy/semantic-release-action@v6 + uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0 with: extra_plugins: | @semantic-release/commit-analyzer @@ -80,12 +101,12 @@ jobs: @semantic-release/git conventional-changelog-conventionalcommits env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GIT_AUTHOR_NAME: devsputio - GIT_AUTHOR_EMAIL: devs@put.io - GIT_COMMITTER_NAME: devsputio - GIT_COMMITTER_EMAIL: devs@put.io + GIT_AUTHOR_NAME: ${{ steps.release-bot.outputs.app-slug }}[bot] + GIT_AUTHOR_EMAIL: ${{ steps.release-bot.outputs.app-slug }}[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: ${{ steps.release-bot.outputs.app-slug }}[bot] + GIT_COMMITTER_EMAIL: ${{ steps.release-bot.outputs.app-slug }}[bot]@users.noreply.github.com build-unix-binaries: if: needs.release.outputs.new_release_published == 'true' @@ -94,6 +115,7 @@ jobs: - release runs-on: ${{ matrix.os }} timeout-minutes: 30 + environment: release permissions: contents: write strategy: @@ -108,25 +130,35 @@ jobs: asset_arch: arm64 steps: + - name: Create release bot token + id: release-bot + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.PUTIO_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + ref: ${{ needs.release.outputs.new_release_git_tag }} - - name: Check out release tag - run: git checkout ${{ needs.release.outputs.new_release_git_tag }} - - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build SEA binary - run: vp run build:sea + run: pnpm run build:sea - name: Verify SEA binary - run: vp run verify:sea + run: pnpm run verify:sea - name: Package release assets shell: pwsh @@ -151,8 +183,9 @@ jobs: run: Get-ChildItem .artifacts/release - name: Upload binary assets to the GitHub release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: + token: ${{ steps.release-bot.outputs.token }} tag_name: ${{ needs.release.outputs.new_release_git_tag }} files: | .artifacts/release/* @@ -164,29 +197,40 @@ jobs: - release runs-on: windows-latest timeout-minutes: 30 + environment: release permissions: contents: write steps: + - name: Create release bot token + id: release-bot + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.PUTIO_RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.PUTIO_RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + ref: ${{ needs.release.outputs.new_release_git_tag }} - - name: Check out release tag - run: git checkout ${{ needs.release.outputs.new_release_git_tag }} - - - name: Set up Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version-file: ".node-version" - cache: true + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build SEA binary - run: vp run build:sea + run: pnpm run build:sea - name: Verify SEA binary - run: vp run verify:sea + run: pnpm run verify:sea - name: Package release assets shell: pwsh @@ -206,8 +250,9 @@ jobs: run: Get-ChildItem .artifacts/release - name: Upload binary assets to the GitHub release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: + token: ${{ steps.release-bot.outputs.token }} tag_name: ${{ needs.release.outputs.new_release_git_tag }} files: | .artifacts/release/* @@ -220,12 +265,13 @@ jobs: - build-unix-binaries runs-on: ubuntu-latest timeout-minutes: 20 + environment: release permissions: contents: read steps: - name: Release to Homebrew tap - uses: Justintime50/homebrew-releaser@v3 + uses: Justintime50/homebrew-releaser@a62d7a359683bfc047cdb2431f53ee58241464d1 # v3 with: homebrew_owner: putdotio homebrew_tap: homebrew-tap diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc55467..780d5f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,14 @@ pnpm run build:sea pnpm run verify:sea ``` +## Release Publishing + +GitHub Actions publishes from `main` through the protected `release` Environment. + +Keep `NPM_TOKEN`, `PUTIO_RELEASE_BOT_APP_ID`, and `PUTIO_RELEASE_BOT_PRIVATE_KEY` in that Environment with required reviewers and prevent self-review enabled. Pull request checks stay secretless and only run verify jobs. + +Release GitHub writes use `putio-release-bot` for version sync commits, `v*` tags, GitHub Releases, and binary asset uploads. Trusted put.io team members may push directly to `main`, but repository rules should block outsiders, force-pushes, and branch deletes where GitHub plan support allows. + ## Development Notes - `verify` is the repository delivery gate. diff --git a/package.json b/package.json index 4b02b08..2965575 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "scripts": { "build": "vp pack", - "build:sea": "node ./scripts/build-sea.mjs", + "build:sea": "node ./scripts/build-sea.mts", "check": "vp check .", "coverage": "vp test --coverage", "dev": "vp pack --watch", diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mts similarity index 65% rename from scripts/build-sea.mjs rename to scripts/build-sea.mts index 4dd2b03..80f7ef0 100644 --- a/scripts/build-sea.mjs +++ b/scripts/build-sea.mts @@ -1,5 +1,14 @@ +import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { createWriteStream, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + createWriteStream, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { mkdir, unlink } from "node:fs/promises"; import { request } from "node:https"; import { dirname, join } from "node:path"; @@ -22,7 +31,14 @@ const seaSentinelFuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; const localBin = (name) => join(root, "node_modules", ".bin", `${name}${platform === "win32" ? ".cmd" : ""}`); -const run = (command, args, options = {}) => { +type RunOptions = { + readonly cwd?: string; + readonly encoding?: BufferEncoding; + readonly stdio?: "inherit"; + readonly windowsVerbatimArguments?: boolean; +}; + +const run = (command: string, args: ReadonlyArray, options: RunOptions = {}) => { if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) { return execFileSync(process.env.comspec ?? "cmd.exe", ["/d", "/s", "/c", command, ...args], { cwd: root, @@ -41,10 +57,10 @@ const run = (command, args, options = {}) => { }); }; -const downloadFile = async (url, destination) => { +const downloadFile = async (url: string, destination: string): Promise => { await mkdir(dirname(destination), { recursive: true }); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const req = request(url, (response) => { if ( response.statusCode && @@ -62,7 +78,7 @@ const downloadFile = async (url, destination) => { return; } - pipeline(response, createWriteStream(destination)).then(resolve, reject); + void pipeline(response, createWriteStream(destination)).then(resolve, reject); }); req.on("error", reject); @@ -70,6 +86,53 @@ const downloadFile = async (url, destination) => { }); }; +const downloadText = async (url: string): Promise => + await new Promise((resolve, reject) => { + const req = request(url, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + typeof response.headers.location === "string" + ) { + response.resume(); + resolve(downloadText(response.headers.location)); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`Unable to download ${url}. Received status ${response.statusCode}.`)); + return; + } + + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + text += chunk; + }); + response.on("end", () => resolve(text)); + response.on("error", reject); + }); + + req.on("error", reject); + req.end(); + }); + +const sha256File = (filePath: string) => + createHash("sha256").update(readFileSync(filePath)).digest("hex"); + +const resolveExpectedChecksum = (shasums: string, archiveName: string) => { + for (const line of shasums.split(/\r?\n/u)) { + const [checksum, filename] = line.trim().split(/\s+/u); + + if (checksum !== undefined && filename === archiveName) { + return checksum; + } + } + + throw new Error(`Unable to resolve checksum for ${archiveName}.`); +}; + const resolveOfficialNodeRuntime = async () => { if (process.env.SEA_NODE_BINARY) { return process.env.SEA_NODE_BINARY; @@ -88,10 +151,22 @@ const resolveOfficialNodeRuntime = async () => { return nodeBinary; } - await downloadFile( - `https://nodejs.org/dist/v${version}/${baseName}.${archiveExtension}`, - archivePath, + const archiveName = `${baseName}.${archiveExtension}`; + const nodeDistBase = `https://nodejs.org/dist/v${version}`; + + await downloadFile(`${nodeDistBase}/${archiveName}`, archivePath); + + const expectedChecksum = resolveExpectedChecksum( + await downloadText(`${nodeDistBase}/SHASUMS256.txt`), + archiveName, ); + const actualChecksum = sha256File(archivePath); + + if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) { + throw new Error( + `Checksum mismatch for ${archiveName}. Expected ${expectedChecksum}, received ${actualChecksum}.`, + ); + } mkdirSync(runtimeDir, { recursive: true }); if (platform === "win32") {