Skip to content

Commit cbe31ef

Browse files
authored
ci: Harden internal GitHub Actions (#12714)
## Summary - Prevent PR-controlled local actions from deciding whether CI can skip release PR tests. - Fail closed when release PR file validation cannot complete or is rejected. - Remove shell interpolation from releaser publish commands and validate release metadata before path/command use.
1 parent 56eefcc commit cbe31ef

5 files changed

Lines changed: 178 additions & 16 deletions

File tree

.github/actions/install-global-turbo/action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ runs:
2626
VERSION=$(npm view turbo --json | jq -r '.versions | map(select(test("^2\\."))) | last')
2727
echo "No version provided, using latest 2.x version: $VERSION"
2828
fi
29-
echo "TURBO_VERSION=$VERSION" >> $GITHUB_ENV
29+
if [[ ! "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$ ]]; then
30+
echo "::error::Invalid Turbo version '${VERSION}'. Use a semver version or safe npm dist-tag."
31+
exit 1
32+
fi
33+
echo "TURBO_VERSION=$VERSION" >> "$GITHUB_ENV"
3034
3135
- name: Install Turbo globally
3236
shell: bash

.github/workflows/test-js-packages.yml

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,71 @@ jobs:
1313
find-changes:
1414
name: Find path changes
1515
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
pull-requests: read
1619
outputs:
1720
is-release-pr: ${{ steps.check.outputs.is-release-pr }}
1821
js-packages: ${{ steps.filter.outputs.js-packages }}
1922
steps:
23+
- name: Check if automated release PR
24+
id: check
25+
shell: bash
26+
env:
27+
EVENT_NAME: ${{ github.event_name }}
28+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
29+
PR_TITLE: ${{ github.event.pull_request.title }}
30+
PR_NUMBER: ${{ github.event.pull_request.number }}
31+
REPOSITORY: ${{ github.repository }}
32+
GH_TOKEN: ${{ github.token }}
33+
run: |
34+
set -euo pipefail
35+
36+
if [[ "$EVENT_NAME" != "pull_request" ]]; then
37+
echo "is-release-pr=false" >> "$GITHUB_OUTPUT"
38+
echo "Not a pull_request event, skipping release PR check"
39+
exit 0
40+
fi
41+
42+
if [[ "$PR_AUTHOR" != "github-actions[bot]" || ! "$PR_TITLE" =~ ^release\(turborepo\): ]]; then
43+
echo "is-release-pr=false" >> "$GITHUB_OUTPUT"
44+
echo "Not a release PR (author: $PR_AUTHOR, title: $PR_TITLE)"
45+
exit 0
46+
fi
47+
48+
CHANGED_FILES=$(gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename')
49+
if [[ -z "$CHANGED_FILES" ]]; then
50+
echo "::error::Unable to determine changed files for release PR"
51+
exit 1
52+
fi
53+
54+
echo "Changed files in release PR:"
55+
printf '%s\n' "$CHANGED_FILES"
56+
57+
ALLOWED_PATTERN="^(version\.txt|.*/package\.json|package\.json|Cargo\.toml|Cargo\.lock|.*/Cargo\.toml|CHANGELOG.*|pnpm-lock\.yaml)$"
58+
INVALID_FILES=""
59+
while IFS= read -r file; do
60+
if [[ -n "$file" && ! "$file" =~ $ALLOWED_PATTERN ]]; then
61+
INVALID_FILES="$INVALID_FILES$file"$'\n'
62+
fi
63+
done <<< "$CHANGED_FILES"
64+
65+
if [[ -n "$INVALID_FILES" ]]; then
66+
echo "::error::Release PR contains unexpected files that are not version-related:"
67+
printf '%s\n' "$INVALID_FILES"
68+
echo "::error::Release PRs should only modify version.txt, package.json, Cargo.toml, Cargo.lock, CHANGELOG, or pnpm-lock.yaml"
69+
exit 1
70+
fi
71+
72+
echo "is-release-pr=true" >> "$GITHUB_OUTPUT"
73+
echo "Release PR content validation passed - only version-related files changed"
74+
2075
- name: Checkout
76+
if: steps.check.outputs.is-release-pr != 'true'
2177
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2278
with:
2379
persist-credentials: false
2480

25-
- name: Check if automated release PR
26-
id: check
27-
uses: ./.github/actions/check-release-pr
28-
2981
- name: Check path changes
3082
if: steps.check.outputs.is-release-pr != 'true'
3183
id: filter
@@ -120,6 +172,10 @@ jobs:
120172
- find-changes
121173
- js_packages
122174
steps:
175+
- name: Fail if change detection failed
176+
if: needs.find-changes.result != 'success'
177+
run: exit 1
178+
123179
- name: Skip for release PR
124180
if: needs.find-changes.outputs.is-release-pr == 'true'
125181
run: echo "Release PR detected - skipping tests (code already tested on main)"

.github/workflows/turborepo-test.yml

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ jobs:
1616
name: Find path changes
1717
runs-on: ubuntu-latest
1818
timeout-minutes: 30
19+
permissions:
20+
contents: read
21+
pull-requests: read
1922
outputs:
2023
is-release-pr: ${{ steps.check-release.outputs.is-release-pr }}
2124
docs: ${{ steps.filter.outputs.docs }}
@@ -26,15 +29,64 @@ jobs:
2629
# Detect automated release PRs which only contain version bumps.
2730
# These PRs are created by the release workflow after code has already
2831
# been tested on main. Skipping tests on version-only changes saves CI time.
32+
- name: Check if automated release PR
33+
id: check-release
34+
shell: bash
35+
env:
36+
EVENT_NAME: ${{ github.event_name }}
37+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
38+
PR_TITLE: ${{ github.event.pull_request.title }}
39+
PR_NUMBER: ${{ github.event.pull_request.number }}
40+
REPOSITORY: ${{ github.repository }}
41+
GH_TOKEN: ${{ github.token }}
42+
run: |
43+
set -euo pipefail
44+
45+
if [[ "$EVENT_NAME" != "pull_request" ]]; then
46+
echo "is-release-pr=false" >> "$GITHUB_OUTPUT"
47+
echo "Not a pull_request event, skipping release PR check"
48+
exit 0
49+
fi
50+
51+
if [[ "$PR_AUTHOR" != "github-actions[bot]" || ! "$PR_TITLE" =~ ^release\(turborepo\): ]]; then
52+
echo "is-release-pr=false" >> "$GITHUB_OUTPUT"
53+
echo "Not a release PR (author: $PR_AUTHOR, title: $PR_TITLE)"
54+
exit 0
55+
fi
56+
57+
CHANGED_FILES=$(gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename')
58+
if [[ -z "$CHANGED_FILES" ]]; then
59+
echo "::error::Unable to determine changed files for release PR"
60+
exit 1
61+
fi
62+
63+
echo "Changed files in release PR:"
64+
printf '%s\n' "$CHANGED_FILES"
65+
66+
ALLOWED_PATTERN="^(version\.txt|.*/package\.json|package\.json|Cargo\.toml|Cargo\.lock|.*/Cargo\.toml|CHANGELOG.*|pnpm-lock\.yaml)$"
67+
INVALID_FILES=""
68+
while IFS= read -r file; do
69+
if [[ -n "$file" && ! "$file" =~ $ALLOWED_PATTERN ]]; then
70+
INVALID_FILES="$INVALID_FILES$file"$'\n'
71+
fi
72+
done <<< "$CHANGED_FILES"
73+
74+
if [[ -n "$INVALID_FILES" ]]; then
75+
echo "::error::Release PR contains unexpected files that are not version-related:"
76+
printf '%s\n' "$INVALID_FILES"
77+
echo "::error::Release PRs should only modify version.txt, package.json, Cargo.toml, Cargo.lock, CHANGELOG, or pnpm-lock.yaml"
78+
exit 1
79+
fi
80+
81+
echo "is-release-pr=true" >> "$GITHUB_OUTPUT"
82+
echo "Release PR content validation passed - only version-related files changed"
83+
2984
- name: Checkout
85+
if: steps.check-release.outputs.is-release-pr != 'true'
3086
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
3187
with:
3288
persist-credentials: false
3389

34-
- name: Check if automated release PR
35-
id: check-release
36-
uses: ./.github/actions/check-release-pr
37-
3890
- name: Check path changes
3991
if: steps.check-release.outputs.is-release-pr != 'true'
4092
id: filter
@@ -653,6 +705,10 @@ jobs:
653705
- check-lockfiles
654706
- js_native_packages
655707
steps:
708+
- name: Fail if change detection failed
709+
if: needs.find-changes.result != 'success'
710+
run: exit 1
711+
656712
- name: Skip for release PR
657713
if: needs.find-changes.outputs.is-release-pr == 'true'
658714
run: |

packages/turbo-releaser/src/operations.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from "node:path";
22
import fs from "node:fs/promises";
3-
import { execSync } from "node:child_process";
3+
import { execFileSync } from "node:child_process";
44
import * as tar from "tar";
55
import native from "./native";
66
import type { Platform } from "./types";
@@ -22,6 +22,33 @@ export interface PackOptions {
2222
description?: string;
2323
}
2424

25+
function validateVersion(version: string) {
26+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$/.test(version)) {
27+
throw new Error(`Invalid version: ${version}`);
28+
}
29+
}
30+
31+
function validateNpmTag(npmTag: string) {
32+
if (!/^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/.test(npmTag)) {
33+
throw new Error(`Invalid npm tag: ${npmTag}`);
34+
}
35+
}
36+
37+
function validatePathSegment(name: string, value: string) {
38+
if (!/^[0-9A-Za-z._-]+$/.test(value) || value.includes("..")) {
39+
throw new Error(`Invalid ${name}: ${value}`);
40+
}
41+
}
42+
43+
function validatePackagePrefix(packagePrefix: string) {
44+
const validPackagePrefix =
45+
/^(@[0-9A-Za-z._-]+(\/[0-9A-Za-z._-]+)?|[0-9A-Za-z._-]+)$/;
46+
47+
if (!validPackagePrefix.test(packagePrefix)) {
48+
throw new Error(`Invalid package prefix: ${packagePrefix}`);
49+
}
50+
}
51+
2552
async function packPlatform({
2653
platform,
2754
version,
@@ -31,11 +58,17 @@ async function packPlatform({
3158
srcDirPrefix = "dist",
3259
description
3360
}: PackOptions): Promise<string> {
61+
validateVersion(version);
62+
validatePackagePrefix(packagePrefix);
63+
validatePathSegment("binary name", binaryBaseName);
64+
validatePathSegment("source directory prefix", srcDirPrefix);
65+
3466
const { os, arch } = platform;
3567
console.log(`Packing platform: ${os}-${arch}`);
3668
const npmDirName = `${packagePrefix}-${os}-${arch}`
3769
.replace("@", "")
3870
.replace("/", "-");
71+
validatePathSegment("package directory name", npmDirName);
3972
const tarballDir = path.join(srcDir, "dist", `${npmDirName}-${version}`);
4073
const scaffoldDir = path.join(tarballDir, npmDirName);
4174

@@ -81,12 +114,23 @@ async function packPlatform({
81114
}
82115

83116
function publishArtifacts(artifacts: Array<string>, npmTag: string) {
117+
validateNpmTag(npmTag);
118+
84119
for (const artifact of artifacts) {
85-
const npmVersion = execSync("npm --version").toString().trim();
120+
const npmVersion = execFileSync("npm", ["--version"], {
121+
encoding: "utf8"
122+
}).trim();
86123
console.log(`npm version: ${npmVersion}`);
87-
const publishCommand = `npm publish "${artifact}" --tag ${npmTag} --access public`;
88-
console.log(`Executing: ${publishCommand}`);
89-
execSync(publishCommand, { stdio: "inherit" });
124+
console.log(
125+
`Executing: npm publish ${artifact} --tag ${npmTag} --access public`
126+
);
127+
execFileSync(
128+
"npm",
129+
["publish", artifact, "--tag", npmTag, "--access", "public"],
130+
{
131+
stdio: "inherit"
132+
}
133+
);
90134
}
91135
}
92136

packages/turbo-releaser/src/packager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync } from "node:child_process";
1+
import { execFileSync } from "node:child_process";
22
import type { Platform } from "./types";
33
import operations from "./operations";
44

@@ -44,7 +44,9 @@ export async function packAndPublish({
4444

4545
if (!skipPublish) {
4646
console.log("Publishing artifacts...");
47-
const npmVersion = execSync("npm --version").toString().trim();
47+
const npmVersion = execFileSync("npm", ["--version"], {
48+
encoding: "utf8"
49+
}).trim();
4850
console.log(`npm version: ${npmVersion}`);
4951
operations.publishArtifacts(artifacts, npmTag);
5052
} else {

0 commit comments

Comments
 (0)