History

Harden release workflow refs

Rhys Sullivan committed 6 months ago

Commit e360eb81 parent 82e014c6 files · +212 −22Browse files

Unified · Split

6 files
  1. .github/workflows/pkg-pr-new.yml +10 −0
  2. .github/workflows/publish-desktop.yml +36 −5
  3. .github/workflows/publish-executor-package.yml +18 −6
  4. .github/workflows/release.yml +23 −11
  5. scripts/validate-release-ref.ts +70 −0
  6. tests/release-workflows.test.ts +55 −0
.github/workflows/pkg-pr-new.yml+10−0
@@ -3,6 +3,9 @@ name: Publish (pkg.pr.new) · 2 unmodified lines
3on:3on:
4 pull_request:4 pull_request:
5 5
6permissions:
7 contents: read
8
6jobs:9jobs:
7 # Per-platform matrix: build the executor binary, tar it, upload to R2.10 # Per-platform matrix: build the executor binary, tar it, upload to R2.
8 # The wrapper npm package is built later by the `publish` job which just11 # The wrapper npm package is built later by the `publish` job which just
@@ -24,6 +27,8 @@ jobs: · 15 unmodified lines
24 runs-on: ${{ matrix.runner }}27 runs-on: ${{ matrix.runner }}
25 steps:28 steps:
26 - uses: actions/checkout@v429 - uses: actions/checkout@v4
30 with:
31 persist-credentials: false
27 32
28 - uses: oven-sh/setup-bun@v233 - uses: oven-sh/setup-bun@v2
29 with:34 with:
@@ -60,8 +65,13 @@ jobs: · 30 unmodified lines
60 name: Publish65 name: Publish
61 needs: build-preview-binary66 needs: build-preview-binary
62 runs-on: ubuntu-latest67 runs-on: ubuntu-latest
68 permissions:
69 contents: read
70 pull-requests: write
63 steps:71 steps:
64 - uses: actions/checkout@v472 - uses: actions/checkout@v4
73 with:
74 persist-credentials: false
65 75
66 - uses: oven-sh/setup-bun@v276 - uses: oven-sh/setup-bun@v2
67 with:77 with:
.github/workflows/publish-desktop.yml+36−5
@@ -10,7 +10,7 @@ on: · 9 unmodified lines
10 type: string10 type: string
11 11
12permissions:12permissions:
13 contents: write13 contents: read
14 14
15concurrency:15concurrency:
16 group: publish-desktop-${{ github.ref }}16 group: publish-desktop-${{ github.ref }}
@@ -18,6 +18,8 @@ concurrency: · 1 unmodified line
18 18
19jobs:19jobs:
20 build:20 build:
21 permissions:
22 contents: read
21 strategy:23 strategy:
22 fail-fast: false24 fail-fast: false
23 matrix:25 matrix:
@@ -45,13 +47,25 @@ jobs: · 21 unmodified lines
45 - name: Checkout47 - name: Checkout
46 uses: actions/checkout@v448 uses: actions/checkout@v4
47 with:49 with:
48 ref: refs/tags/${{ inputs.tag }}50 persist-credentials: false
49 51
50 - name: Setup Bun52 - name: Setup Bun
51 uses: oven-sh/setup-bun@v253 uses: oven-sh/setup-bun@v2
52 with:54 with:
53 bun-version: 1.3.1155 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 - name: Setup Node69 - name: Setup Node
56 uses: actions/setup-node@v470 uses: actions/setup-node@v4
57 with:71 with:
@@ -70,7 +84,7 @@ jobs: · 12 unmodified lines
70 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}84 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
71 run: |85 run: |
72 mkdir -p apps/desktop/resources86 mkdir -p apps/desktop/resources
73 gh release download "${{ inputs.tag }}" --repo "${{ github.repository }}" --pattern "${{ matrix.cli-asset }}" --dir /tmp/cli-download87 gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "${{ matrix.cli-asset }}" --dir /tmp/cli-download
74 cd /tmp/cli-download88 cd /tmp/cli-download
75 if [[ "${{ matrix.cli-asset }}" == *.tar.gz ]]; then89 if [[ "${{ matrix.cli-asset }}" == *.tar.gz ]]; then
76 tar -xzf "${{ matrix.cli-asset }}"90 tar -xzf "${{ matrix.cli-asset }}"
@@ -90,7 +104,7 @@ jobs: · 13 unmodified lines
90 shell: pwsh104 shell: pwsh
91 run: |105 run: |
92 New-Item -ItemType Directory -Force -Path "apps/desktop/resources"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 Expand-Archive -Path "$env:TEMP/cli-download/${{ matrix.cli-asset }}" -DestinationPath "$env:TEMP/cli-extract" -Force108 Expand-Archive -Path "$env:TEMP/cli-download/${{ matrix.cli-asset }}" -DestinationPath "$env:TEMP/cli-extract" -Force
95 Copy-Item "$env:TEMP/cli-extract/executor.exe" "apps/desktop/resources/executor.exe"109 Copy-Item "$env:TEMP/cli-extract/executor.exe" "apps/desktop/resources/executor.exe"
96 if (Test-Path "$env:TEMP/cli-extract/emscripten-module.wasm") {110 if (Test-Path "$env:TEMP/cli-extract/emscripten-module.wasm") {
@@ -119,8 +133,25 @@ jobs: · 22 unmodified lines
119 release:133 release:
120 needs: build134 needs: build
121 runs-on: ubuntu-latest135 runs-on: ubuntu-latest
136 permissions:
137 contents: write
122 138
123 steps: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 - name: Download all artifacts155 - name: Download all artifacts
125 uses: actions/download-artifact@v4156 uses: actions/download-artifact@v4
126 with:157 with:
@@ -133,5 +164,5 @@ jobs: · 6 unmodified lines
133 run: |164 run: |
134 find artifacts -type f \( -name "*.dmg" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | while read file; do165 find artifacts -type f \( -name "*.dmg" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | while read file; do
135 echo "Uploading: $file"166 echo "Uploading: $file"
136 gh release upload "${{ inputs.tag }}" "$file" --repo "${{ github.repository }}" --clobber || true167 gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber || true
137 done168 done
.github/workflows/publish-executor-package.yml+18−6
@@ -13,9 +13,7 @@ on: · 12 unmodified lines
13 type: string13 type: string
14 14
15permissions:15permissions:
16 actions: write16 contents: read
17 contents: write
18 id-token: write
19 17
20concurrency:18concurrency:
21 group: publish-executor-package-${{ github.ref }}19 group: publish-executor-package-${{ github.ref }}
@@ -24,19 +22,35 @@ concurrency: · 2 unmodified lines
24jobs:22jobs:
25 publish:23 publish:
26 runs-on: ubuntu-latest24 runs-on: ubuntu-latest
25 permissions:
26 actions: write
27 contents: write
28 id-token: write
27 29
28 steps:30 steps:
29 - name: Checkout31 - name: Checkout
30 uses: actions/checkout@v432 uses: actions/checkout@v4
31 with:33 with:
32 fetch-depth: 034 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 - name: Setup Bun37 - name: Setup Bun
36 uses: oven-sh/setup-bun@v238 uses: oven-sh/setup-bun@v2
37 with:39 with:
38 bun-version: 1.3.1140 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 - name: Setup Node54 - name: Setup Node
41 uses: actions/setup-node@v455 uses: actions/setup-node@v4
42 with:56 with:
@@ -57,7 +71,6 @@ jobs: · 14 unmodified lines
57 - name: Publish package and create release71 - name: Publish package and create release
58 env:72 env:
59 GH_TOKEN: ${{ github.token }}73 GH_TOKEN: ${{ github.token }}
60 RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
61 run: |74 run: |
62 export GITHUB_REF_TYPE=tag75 export GITHUB_REF_TYPE=tag
63 export GITHUB_REF_NAME="$RELEASE_TAG"76 export GITHUB_REF_NAME="$RELEASE_TAG"
@@ -67,5 +80,4 @@ jobs: · 3 unmodified lines
67 - name: Trigger desktop build80 - name: Trigger desktop build
68 env:81 env:
69 GH_TOKEN: ${{ github.token }}82 GH_TOKEN: ${{ github.token }}
70 RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
71 run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG"83 run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG"
.github/workflows/release.yml+23−11
@@ -7,10 +7,7 @@ on: · 6 unmodified lines
7 - main7 - main
8 8
9permissions:9permissions:
10 actions: write10 contents: read
11 contents: write
12 id-token: write
13 pull-requests: write
14 11
15concurrency:12concurrency:
16 group: release-${{ github.ref }}13 group: release-${{ github.ref }}
@@ -19,12 +16,18 @@ concurrency: · 2 unmodified lines
19jobs:16jobs:
20 release:17 release:
21 runs-on: ubuntu-latest18 runs-on: ubuntu-latest
19 permissions:
20 actions: write
21 contents: write
22 id-token: write
23 pull-requests: write
22 24
23 steps:25 steps:
24 - name: Checkout26 - name: Checkout
25 uses: actions/checkout@v427 uses: actions/checkout@v4
26 with:28 with:
27 fetch-depth: 029 fetch-depth: 0
30 persist-credentials: false
28 31
29 - name: Setup Bun32 - name: Setup Bun
30 uses: oven-sh/setup-bun@v233 uses: oven-sh/setup-bun@v2
@@ -84,27 +87,36 @@ jobs: · 53 unmodified lines
84 87
85 echo "version=$version" >> "$GITHUB_OUTPUT"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 - name: Create and push release tag97 - name: Create and push release tag
88 if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true'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 run: |102 run: |
90 tag="v${{ steps.detect_release.outputs.version }}"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
91 if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then104 echo "Tag $RELEASE_TAG already exists."
92 echo "Tag $tag already exists."
93 exit 0105 exit 0
94 fi106 fi
95 107
96 git config user.name "github-actions[bot]"108 git config user.name "github-actions[bot]"
97 git config user.email "41898282+github-actions[bot]@users.noreply.github.com"109 git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
98 git tag "$tag"110 git tag "$RELEASE_TAG"
99 git push origin "$tag"111 git -c http.extraheader="AUTHORIZATION: bearer $GH_TOKEN" push origin "$RELEASE_TAG"
100 112
101 - name: Trigger CLI publish113 - name: Trigger CLI publish
102 if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true'114 if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true'
103 env:115 env:
104 GH_TOKEN: ${{ github.token }}116 GH_TOKEN: ${{ github.token }}
117 RELEASE_TAG: ${{ steps.validate_release.outputs.tag }}
105 run: |118 run: |
106 tag="v${{ steps.detect_release.outputs.version }}"119 gh workflow run publish-executor-package.yml --ref "$RELEASE_TAG" -f tag="$RELEASE_TAG"
107 gh workflow run publish-executor-package.yml --ref "$tag" -f tag="$tag"
108 120
109 # Desktop build downloads CLI binaries from the release, so it must121 # Desktop build downloads CLI binaries from the release, so it must
110 # run after CLI publish completes. Trigger it from the CLI workflow122 # run after CLI publish completes. Trigger it from the CLI workflow
scripts/validate-release-ref.ts+70−0
@@ -0,0 +1,70 @@
1#!/usr/bin/env bun
2import { appendFileSync } from "node:fs";
3
4const 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
7export 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
14export 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
22const 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
30const appendGitHubFile = (path: string | undefined, line: string): void => {
31 if (!path || !line) return;
32 appendFileSync(path, `${line}\n`, "utf8");
33};
34
35const 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
58const 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
68if (import.meta.main) {
69 run();
70}
tests/release-workflows.test.ts+55−0
@@ -0,0 +1,55 @@
1import { describe, expect, it } from "@effect/vitest";
2import { readFileSync } from "node:fs";
3import { resolve } from "node:path";
4
5import { validateReleaseTag, validateReleaseVersion } from "../scripts/validate-release-ref";
6
7const workflow = (name: string): string =>
8 readFileSync(resolve(import.meta.dirname, "..", ".github/workflows", name), "utf8");
9
10describe("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});