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
33 on:
44 pull_request:
55
6+permissions:
7+ contents: read
8+
69 jobs:
710 # Per-platform matrix: build the executor binary, tar it, upload to R2.
811 # The wrapper npm package is built later by the `publish` job which just
@@ -24,6 +27,8 @@ jobs: · 15 unmodified lines
2427 runs-on: ${{ matrix.runner }}
2528 steps:
2629 - uses: actions/checkout@v4
30+ with:
31+ persist-credentials: false
2732
2833 - uses: oven-sh/setup-bun@v2
2934 with:
@@ -60,8 +65,13 @@ jobs: · 30 unmodified lines
6065 name: Publish
6166 needs: build-preview-binary
6267 runs-on: ubuntu-latest
68+ permissions:
69+ contents: read
70+ pull-requests: write
6371 steps:
6472 - uses: actions/checkout@v4
73+ with:
74+ persist-credentials: false
6575
6676 - uses: oven-sh/setup-bun@v2
6777 with:
.github/workflows/publish-desktop.yml+36−5
@@ -10,7 +10,7 @@ on: · 9 unmodified lines
1010 type: string
1111
1212 permissions:
13− contents: write
13+ contents: read
1414
1515 concurrency:
1616 group: publish-desktop-${{ github.ref }}
@@ -18,6 +18,8 @@ concurrency: · 1 unmodified line
1818
1919 jobs:
2020 build:
21+ permissions:
22+ contents: read
2123 strategy:
2224 fail-fast: false
2325 matrix:
@@ -45,13 +47,25 @@ jobs: · 21 unmodified lines
4547 - name: Checkout
4648 uses: actions/checkout@v4
4749 with:
48− ref: refs/tags/${{ inputs.tag }}
50+ persist-credentials: false
4951
5052 - name: Setup Bun
5153 uses: oven-sh/setup-bun@v2
5254 with:
5355 bun-version: 1.3.11
5456
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+
5569 - name: Setup Node
5670 uses: actions/setup-node@v4
5771 with:
@@ -70,7 +84,7 @@ jobs: · 12 unmodified lines
7084 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7185 run: |
7286 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
7488 cd /tmp/cli-download
7589 if [[ "${{ matrix.cli-asset }}" == *.tar.gz ]]; then
7690 tar -xzf "${{ matrix.cli-asset }}"
@@ -90,7 +104,7 @@ jobs: · 13 unmodified lines
90104 shell: pwsh
91105 run: |
92106 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"
94108 Expand-Archive -Path "$env:TEMP/cli-download/${{ matrix.cli-asset }}" -DestinationPath "$env:TEMP/cli-extract" -Force
95109 Copy-Item "$env:TEMP/cli-extract/executor.exe" "apps/desktop/resources/executor.exe"
96110 if (Test-Path "$env:TEMP/cli-extract/emscripten-module.wasm") {
@@ -119,8 +133,25 @@ jobs: · 22 unmodified lines
119133 release:
120134 needs: build
121135 runs-on: ubuntu-latest
136+ permissions:
137+ contents: write
122138
123139 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+
124155 - name: Download all artifacts
125156 uses: actions/download-artifact@v4
126157 with:
@@ -133,5 +164,5 @@ jobs: · 6 unmodified lines
133164 run: |
134165 find artifacts -type f \( -name "*.dmg" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | while read file; do
135166 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
137168 done
.github/workflows/publish-executor-package.yml+18−6
@@ -13,9 +13,7 @@ on: · 12 unmodified lines
1313 type: string
1414
1515 permissions:
16− actions: write
17− contents: write
18− id-token: write
16+ contents: read
1917
2018 concurrency:
2119 group: publish-executor-package-${{ github.ref }}
@@ -24,19 +22,35 @@ concurrency: · 2 unmodified lines
2422 jobs:
2523 publish:
2624 runs-on: ubuntu-latest
25+ permissions:
26+ actions: write
27+ contents: write
28+ id-token: write
2729
2830 steps:
2931 - name: Checkout
3032 uses: actions/checkout@v4
3133 with:
3234 fetch-depth: 0
33− ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
35+ persist-credentials: false
3436
3537 - name: Setup Bun
3638 uses: oven-sh/setup-bun@v2
3739 with:
3840 bun-version: 1.3.11
3941
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+
4054 - name: Setup Node
4155 uses: actions/setup-node@v4
4256 with:
@@ -57,7 +71,6 @@ jobs: · 14 unmodified lines
5771 - name: Publish package and create release
5872 env:
5973 GH_TOKEN: ${{ github.token }}
60− RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
6174 run: |
6275 export GITHUB_REF_TYPE=tag
6376 export GITHUB_REF_NAME="$RELEASE_TAG"
@@ -67,5 +80,4 @@ jobs: · 3 unmodified lines
6780 - name: Trigger desktop build
6881 env:
6982 GH_TOKEN: ${{ github.token }}
70− RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
7183 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
77 - main
88
99 permissions:
10− actions: write
11− contents: write
12− id-token: write
13− pull-requests: write
10+ contents: read
1411
1512 concurrency:
1613 group: release-${{ github.ref }}
@@ -19,12 +16,18 @@ concurrency: · 2 unmodified lines
1916 jobs:
2017 release:
2118 runs-on: ubuntu-latest
19+ permissions:
20+ actions: write
21+ contents: write
22+ id-token: write
23+ pull-requests: write
2224
2325 steps:
2426 - name: Checkout
2527 uses: actions/checkout@v4
2628 with:
2729 fetch-depth: 0
30+ persist-credentials: false
2831
2932 - name: Setup Bun
3033 uses: oven-sh/setup-bun@v2
@@ -84,27 +87,36 @@ jobs: · 53 unmodified lines
8487
8588 echo "version=$version" >> "$GITHUB_OUTPUT"
8689
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+
8797 - name: Create and push release tag
8898 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 }}
89102 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."
93105 exit 0
94106 fi
95107
96108 git config user.name "github-actions[bot]"
97109 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"
100112
101113 - name: Trigger CLI publish
102114 if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true'
103115 env:
104116 GH_TOKEN: ${{ github.token }}
117+ RELEASE_TAG: ${{ steps.validate_release.outputs.tag }}
105118 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"
108120
109121 # Desktop build downloads CLI binaries from the release, so it must
110122 # 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
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+}
tests/release-workflows.test.ts+55−0
@@ -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+});