Unified · Split
| @@ -3,6 +3,9 @@ name: Publish (pkg.pr.new) · 2 unmodified lines | ||
| 3 | 3 | on: |
| 4 | 4 | pull_request: |
| 5 | 5 | |
| 6 | +permissions: | |
| 7 | + contents: read | |
| 8 | + | |
| 6 | 9 | jobs: |
| 7 | 10 | # Per-platform matrix: build the executor binary, tar it, upload to R2. |
| 8 | 11 | # The wrapper npm package is built later by the `publish` job which just |
| @@ -24,6 +27,8 @@ jobs: · 15 unmodified lines | ||
| 24 | 27 | runs-on: ${{ matrix.runner }} |
| 25 | 28 | steps: |
| 26 | 29 | - uses: actions/checkout@v4 |
| 30 | + with: | |
| 31 | + persist-credentials: false | |
| 27 | 32 | |
| 28 | 33 | - uses: oven-sh/setup-bun@v2 |
| 29 | 34 | with: |
| @@ -60,8 +65,13 @@ jobs: · 30 unmodified lines | ||
| 60 | 65 | name: Publish |
| 61 | 66 | needs: build-preview-binary |
| 62 | 67 | runs-on: ubuntu-latest |
| 68 | + permissions: | |
| 69 | + contents: read | |
| 70 | + pull-requests: write | |
| 63 | 71 | steps: |
| 64 | 72 | - uses: actions/checkout@v4 |
| 73 | + with: | |
| 74 | + persist-credentials: false | |
| 65 | 75 | |
| 66 | 76 | - uses: oven-sh/setup-bun@v2 |
| 67 | 77 | with: |
| @@ -10,7 +10,7 @@ on: · 9 unmodified lines | ||
| 10 | 10 | type: string |
| 11 | 11 | |
| 12 | 12 | permissions: |
| 13 | − contents: write | |
| 13 | + contents: read | |
| 14 | 14 | |
| 15 | 15 | concurrency: |
| 16 | 16 | group: publish-desktop-${{ github.ref }} |
| @@ -18,6 +18,8 @@ concurrency: · 1 unmodified line | ||
| 18 | 18 | |
| 19 | 19 | jobs: |
| 20 | 20 | build: |
| 21 | + permissions: | |
| 22 | + contents: read | |
| 21 | 23 | strategy: |
| 22 | 24 | fail-fast: false |
| 23 | 25 | matrix: |
| @@ -45,13 +47,25 @@ jobs: · 21 unmodified lines | ||
| 45 | 47 | - name: Checkout |
| 46 | 48 | uses: actions/checkout@v4 |
| 47 | 49 | with: |
| 48 | − ref: refs/tags/${{ inputs.tag }} | |
| 50 | + persist-credentials: false | |
| 49 | 51 | |
| 50 | 52 | - name: Setup Bun |
| 51 | 53 | uses: oven-sh/setup-bun@v2 |
| 52 | 54 | with: |
| 53 | 55 | bun-version: 1.3.11 |
| 54 | 56 | |
| 57 | + - name: Validate release tag | |
| 58 | + env: | |
| 59 | + RAW_RELEASE_TAG: ${{ inputs.tag }} | |
| 60 | + run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG | |
| 61 | + | |
| 62 | + - name: Checkout release tag | |
| 63 | + env: | |
| 64 | + GH_TOKEN: ${{ github.token }} | |
| 65 | + run: | | |
| 66 | + git -c http.extraheader="AUTHORIZATION: bearer $GH_TOKEN" fetch --force --tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" | |
| 67 | + git checkout --detach "$RELEASE_TAG" | |
| 68 | + | |
| 55 | 69 | - name: Setup Node |
| 56 | 70 | uses: actions/setup-node@v4 |
| 57 | 71 | with: |
| @@ -70,7 +84,7 @@ jobs: · 12 unmodified lines | ||
| 70 | 84 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 71 | 85 | run: | |
| 72 | 86 | mkdir -p apps/desktop/resources |
| 73 | − gh release download "${{ inputs.tag }}" --repo "${{ github.repository }}" --pattern "${{ matrix.cli-asset }}" --dir /tmp/cli-download | |
| 87 | + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ matrix.cli-asset }}" --dir /tmp/cli-download | |
| 74 | 88 | cd /tmp/cli-download |
| 75 | 89 | if [[ "${{ matrix.cli-asset }}" == *.tar.gz ]]; then |
| 76 | 90 | tar -xzf "${{ matrix.cli-asset }}" |
| @@ -90,7 +104,7 @@ jobs: · 13 unmodified lines | ||
| 90 | 104 | shell: pwsh |
| 91 | 105 | run: | |
| 92 | 106 | New-Item -ItemType Directory -Force -Path "apps/desktop/resources" |
| 93 | − gh release download "${{ inputs.tag }}" --repo "${{ github.repository }}" --pattern "${{ matrix.cli-asset }}" --dir "$env:TEMP/cli-download" | |
| 107 | + gh release download "$env:RELEASE_TAG" --repo "$env:GITHUB_REPOSITORY" --pattern "${{ matrix.cli-asset }}" --dir "$env:TEMP/cli-download" | |
| 94 | 108 | Expand-Archive -Path "$env:TEMP/cli-download/${{ matrix.cli-asset }}" -DestinationPath "$env:TEMP/cli-extract" -Force |
| 95 | 109 | Copy-Item "$env:TEMP/cli-extract/executor.exe" "apps/desktop/resources/executor.exe" |
| 96 | 110 | if (Test-Path "$env:TEMP/cli-extract/emscripten-module.wasm") { |
| @@ -119,8 +133,25 @@ jobs: · 22 unmodified lines | ||
| 119 | 133 | release: |
| 120 | 134 | needs: build |
| 121 | 135 | runs-on: ubuntu-latest |
| 136 | + permissions: | |
| 137 | + contents: write | |
| 122 | 138 | |
| 123 | 139 | steps: |
| 140 | + - name: Checkout validation script | |
| 141 | + uses: actions/checkout@v4 | |
| 142 | + with: | |
| 143 | + persist-credentials: false | |
| 144 | + | |
| 145 | + - name: Setup Bun | |
| 146 | + uses: oven-sh/setup-bun@v2 | |
| 147 | + with: | |
| 148 | + bun-version: 1.3.11 | |
| 149 | + | |
| 150 | + - name: Validate release tag | |
| 151 | + env: | |
| 152 | + RAW_RELEASE_TAG: ${{ inputs.tag }} | |
| 153 | + run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG | |
| 154 | + | |
| 124 | 155 | - name: Download all artifacts |
| 125 | 156 | uses: actions/download-artifact@v4 |
| 126 | 157 | with: |
| @@ -133,5 +164,5 @@ jobs: · 6 unmodified lines | ||
| 133 | 164 | run: | |
| 134 | 165 | find artifacts -type f \( -name "*.dmg" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | while read file; do |
| 135 | 166 | echo "Uploading: $file" |
| 136 | − gh release upload "${{ inputs.tag }}" "$file" --repo "${{ github.repository }}" --clobber || true | |
| 167 | + gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber || true | |
| 137 | 168 | done |
| @@ -13,9 +13,7 @@ on: · 12 unmodified lines | ||
| 13 | 13 | type: string |
| 14 | 14 | |
| 15 | 15 | permissions: |
| 16 | − actions: write | |
| 17 | − contents: write | |
| 18 | − id-token: write | |
| 16 | + contents: read | |
| 19 | 17 | |
| 20 | 18 | concurrency: |
| 21 | 19 | group: publish-executor-package-${{ github.ref }} |
| @@ -24,19 +22,35 @@ concurrency: · 2 unmodified lines | ||
| 24 | 22 | jobs: |
| 25 | 23 | publish: |
| 26 | 24 | runs-on: ubuntu-latest |
| 25 | + permissions: | |
| 26 | + actions: write | |
| 27 | + contents: write | |
| 28 | + id-token: write | |
| 27 | 29 | |
| 28 | 30 | steps: |
| 29 | 31 | - name: Checkout |
| 30 | 32 | uses: actions/checkout@v4 |
| 31 | 33 | with: |
| 32 | 34 | fetch-depth: 0 |
| 33 | − ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} | |
| 35 | + persist-credentials: false | |
| 34 | 36 | |
| 35 | 37 | - name: Setup Bun |
| 36 | 38 | uses: oven-sh/setup-bun@v2 |
| 37 | 39 | with: |
| 38 | 40 | bun-version: 1.3.11 |
| 39 | 41 | |
| 42 | + - name: Validate release tag | |
| 43 | + env: | |
| 44 | + RAW_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| 45 | + run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG | |
| 46 | + | |
| 47 | + - name: Checkout release tag | |
| 48 | + env: | |
| 49 | + GH_TOKEN: ${{ github.token }} | |
| 50 | + run: | | |
| 51 | + git -c http.extraheader="AUTHORIZATION: bearer $GH_TOKEN" fetch --force --tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" | |
| 52 | + git checkout --detach "$RELEASE_TAG" | |
| 53 | + | |
| 40 | 54 | - name: Setup Node |
| 41 | 55 | uses: actions/setup-node@v4 |
| 42 | 56 | with: |
| @@ -57,7 +71,6 @@ jobs: · 14 unmodified lines | ||
| 57 | 71 | - name: Publish package and create release |
| 58 | 72 | env: |
| 59 | 73 | GH_TOKEN: ${{ github.token }} |
| 60 | − RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| 61 | 74 | run: | |
| 62 | 75 | export GITHUB_REF_TYPE=tag |
| 63 | 76 | export GITHUB_REF_NAME="$RELEASE_TAG" |
| @@ -67,5 +80,4 @@ jobs: · 3 unmodified lines | ||
| 67 | 80 | - name: Trigger desktop build |
| 68 | 81 | env: |
| 69 | 82 | GH_TOKEN: ${{ github.token }} |
| 70 | − RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| 71 | 83 | run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" |
| @@ -7,10 +7,7 @@ on: · 6 unmodified lines | ||
| 7 | 7 | - main |
| 8 | 8 | |
| 9 | 9 | permissions: |
| 10 | − actions: write | |
| 11 | − contents: write | |
| 12 | − id-token: write | |
| 13 | − pull-requests: write | |
| 10 | + contents: read | |
| 14 | 11 | |
| 15 | 12 | concurrency: |
| 16 | 13 | group: release-${{ github.ref }} |
| @@ -19,12 +16,18 @@ concurrency: · 2 unmodified lines | ||
| 19 | 16 | jobs: |
| 20 | 17 | release: |
| 21 | 18 | runs-on: ubuntu-latest |
| 19 | + permissions: | |
| 20 | + actions: write | |
| 21 | + contents: write | |
| 22 | + id-token: write | |
| 23 | + pull-requests: write | |
| 22 | 24 | |
| 23 | 25 | steps: |
| 24 | 26 | - name: Checkout |
| 25 | 27 | uses: actions/checkout@v4 |
| 26 | 28 | with: |
| 27 | 29 | fetch-depth: 0 |
| 30 | + persist-credentials: false | |
| 28 | 31 | |
| 29 | 32 | - name: Setup Bun |
| 30 | 33 | uses: oven-sh/setup-bun@v2 |
| @@ -84,27 +87,36 @@ jobs: · 53 unmodified lines | ||
| 84 | 87 | |
| 85 | 88 | echo "version=$version" >> "$GITHUB_OUTPUT" |
| 86 | 89 | |
| 90 | + - name: Validate release tag | |
| 91 | + if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' | |
| 92 | + id: validate_release | |
| 93 | + env: | |
| 94 | + RELEASE_VERSION: ${{ steps.detect_release.outputs.version }} | |
| 95 | + run: bun run scripts/validate-release-ref.ts --version-env RELEASE_VERSION --output tag | |
| 96 | + | |
| 87 | 97 | - name: Create and push release tag |
| 88 | 98 | if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' |
| 99 | + env: | |
| 100 | + GH_TOKEN: ${{ github.token }} | |
| 101 | + RELEASE_TAG: ${{ steps.validate_release.outputs.tag }} | |
| 89 | 102 | run: | |
| 90 | − tag="v${{ steps.detect_release.outputs.version }}" | |
| 91 | − if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then | |
| 92 | − echo "Tag $tag already exists." | |
| 103 | + if git -c http.extraheader="AUTHORIZATION: bearer $GH_TOKEN" ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then | |
| 104 | + echo "Tag $RELEASE_TAG already exists." | |
| 93 | 105 | exit 0 |
| 94 | 106 | fi |
| 95 | 107 | |
| 96 | 108 | git config user.name "github-actions[bot]" |
| 97 | 109 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 98 | − git tag "$tag" | |
| 99 | − git push origin "$tag" | |
| 110 | + git tag "$RELEASE_TAG" | |
| 111 | + git -c http.extraheader="AUTHORIZATION: bearer $GH_TOKEN" push origin "$RELEASE_TAG" | |
| 100 | 112 | |
| 101 | 113 | - name: Trigger CLI publish |
| 102 | 114 | if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' |
| 103 | 115 | env: |
| 104 | 116 | GH_TOKEN: ${{ github.token }} |
| 117 | + RELEASE_TAG: ${{ steps.validate_release.outputs.tag }} | |
| 105 | 118 | run: | |
| 106 | − tag="v${{ steps.detect_release.outputs.version }}" | |
| 107 | − gh workflow run publish-executor-package.yml --ref "$tag" -f tag="$tag" | |
| 119 | + gh workflow run publish-executor-package.yml --ref "$RELEASE_TAG" -f tag="$RELEASE_TAG" | |
| 108 | 120 | |
| 109 | 121 | # Desktop build downloads CLI binaries from the release, so it must |
| 110 | 122 | # run after CLI publish completes. Trigger it from the CLI workflow |
| @@ -0,0 +1,70 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | +import { appendFileSync } from "node:fs"; | |
| 3 | + | |
| 4 | +const RELEASE_VERSION_PATTERN = | |
| 5 | + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u; | |
| 6 | + | |
| 7 | +export const validateReleaseVersion = (value: string): string => { | |
| 8 | + if (!RELEASE_VERSION_PATTERN.test(value)) { | |
| 9 | + throw new Error(`Invalid release version: ${value}`); | |
| 10 | + } | |
| 11 | + return value; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export const validateReleaseTag = (value: string): string => { | |
| 15 | + if (!value.startsWith("v")) { | |
| 16 | + throw new Error(`Invalid release tag: ${value}`); | |
| 17 | + } | |
| 18 | + validateReleaseVersion(value.slice(1)); | |
| 19 | + return value; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +const readEnv = (name: string): string => { | |
| 23 | + const value = process.env[name]; | |
| 24 | + if (!value) { | |
| 25 | + throw new Error(`Missing environment variable: ${name}`); | |
| 26 | + } | |
| 27 | + return value; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +const appendGitHubFile = (path: string | undefined, line: string): void => { | |
| 31 | + if (!path || !line) return; | |
| 32 | + appendFileSync(path, `${line}\n`, "utf8"); | |
| 33 | +}; | |
| 34 | + | |
| 35 | +const run = (): void => { | |
| 36 | + const args = process.argv.slice(2); | |
| 37 | + const tagEnv = readOption(args, "--tag-env"); | |
| 38 | + const versionEnv = readOption(args, "--version-env"); | |
| 39 | + const writeEnv = readOption(args, "--write-env"); | |
| 40 | + const outputName = readOption(args, "--output"); | |
| 41 | + | |
| 42 | + if (tagEnv && versionEnv) { | |
| 43 | + throw new Error("Use either --tag-env or --version-env, not both"); | |
| 44 | + } | |
| 45 | + if (!tagEnv && !versionEnv) { | |
| 46 | + throw new Error("Expected --tag-env or --version-env"); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const tag = tagEnv | |
| 50 | + ? validateReleaseTag(readEnv(tagEnv)) | |
| 51 | + : `v${validateReleaseVersion(readEnv(versionEnv!))}`; | |
| 52 | + | |
| 53 | + appendGitHubFile(process.env.GITHUB_ENV, writeEnv ? `${writeEnv}=${tag}` : ""); | |
| 54 | + appendGitHubFile(process.env.GITHUB_OUTPUT, outputName ? `${outputName}=${tag}` : ""); | |
| 55 | + console.log(`Validated release tag ${tag}`); | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const readOption = (args: ReadonlyArray<string>, name: string): string | undefined => { | |
| 59 | + const index = args.indexOf(name); | |
| 60 | + if (index === -1) return undefined; | |
| 61 | + const value = args[index + 1]; | |
| 62 | + if (!value || value.startsWith("--")) { | |
| 63 | + throw new Error(`Missing value for ${name}`); | |
| 64 | + } | |
| 65 | + return value; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +if (import.meta.main) { | |
| 69 | + run(); | |
| 70 | +} | |
| @@ -0,0 +1,55 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { readFileSync } from "node:fs"; | |
| 3 | +import { resolve } from "node:path"; | |
| 4 | + | |
| 5 | +import { validateReleaseTag, validateReleaseVersion } from "../scripts/validate-release-ref"; | |
| 6 | + | |
| 7 | +const workflow = (name: string): string => | |
| 8 | + readFileSync(resolve(import.meta.dirname, "..", ".github/workflows", name), "utf8"); | |
| 9 | + | |
| 10 | +describe("release workflow hardening", () => { | |
| 11 | + it("accepts only semver release versions and v-prefixed semver tags", () => { | |
| 12 | + expect(validateReleaseVersion("1.2.3")).toBe("1.2.3"); | |
| 13 | + expect(validateReleaseVersion("1.2.3-beta.4")).toBe("1.2.3-beta.4"); | |
| 14 | + expect(validateReleaseTag("v1.2.3")).toBe("v1.2.3"); | |
| 15 | + expect(validateReleaseTag("v1.2.3-beta.4+build.5")).toBe("v1.2.3-beta.4+build.5"); | |
| 16 | + | |
| 17 | + expect(() => validateReleaseVersion("v1.2.3")).toThrow(); | |
| 18 | + expect(() => validateReleaseVersion("1.2")).toThrow(); | |
| 19 | + expect(() => validateReleaseTag("1.2.3")).toThrow(); | |
| 20 | + expect(() => validateReleaseTag("v1.2.3; echo unsafe")).toThrow(); | |
| 21 | + expect(() => validateReleaseTag("v01.2.3")).toThrow(); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("validates release values through environment variables before shell use", () => { | |
| 25 | + const publishExecutor = workflow("publish-executor-package.yml"); | |
| 26 | + const release = workflow("release.yml"); | |
| 27 | + const publishDesktop = workflow("publish-desktop.yml"); | |
| 28 | + | |
| 29 | + expect(publishExecutor).toContain( | |
| 30 | + "bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG", | |
| 31 | + ); | |
| 32 | + expect(release).toContain( | |
| 33 | + "bun run scripts/validate-release-ref.ts --version-env RELEASE_VERSION --output tag", | |
| 34 | + ); | |
| 35 | + expect(publishDesktop).toContain( | |
| 36 | + "bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG", | |
| 37 | + ); | |
| 38 | + | |
| 39 | + expect(release).not.toContain('tag="v${{ steps.detect_release.outputs.version }}"'); | |
| 40 | + expect(publishDesktop).not.toContain("ref: refs/tags/${{ inputs.tag }}"); | |
| 41 | + expect(publishDesktop).not.toContain('gh release download "${{ inputs.tag }}"'); | |
| 42 | + expect(publishExecutor).not.toMatch(/\n\s+RELEASE_TAG: \$\{\{ github\.event_name/u); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("does not persist checkout credentials in release workflows", () => { | |
| 46 | + for (const name of [ | |
| 47 | + "publish-executor-package.yml", | |
| 48 | + "release.yml", | |
| 49 | + "publish-desktop.yml", | |
| 50 | + "pkg-pr-new.yml", | |
| 51 | + ]) { | |
| 52 | + expect(workflow(name)).toContain("persist-credentials: false"); | |
| 53 | + } | |
| 54 | + }); | |
| 55 | +}); | |