From 33de3f1df5bdb9a6a480e9217d566df7b5a48b8e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 26 Aug 2026 14:15:22 +0000 Subject: [PATCH 1/3] feat: accept workspace publish requests --- .github/workflows/publish.yml | 27 ++- src/modules/__tests__/details-from-context.js | 212 +++++++++++++++++- src/modules/__tests__/publish-workflow.js | 82 +++++++ src/modules/__tests__/update-issue.js | 15 +- src/modules/details-from-context.js | 27 ++- src/modules/update-issue.js | 54 +++-- 6 files changed, 377 insertions(+), 40 deletions(-) create mode 100644 src/modules/__tests__/publish-workflow.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cff6bc3..c283b79 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -184,12 +184,14 @@ jobs: fetch-depth: 0 - name: Set targets + id: craft-state shell: bash if: fromJSON(steps.inputs.outputs.result).targets env: CRAFT_PUBLISH_REPO: ${{ fromJSON(steps.inputs.outputs.result).repo }} CRAFT_PUBLISH_PATH: ${{ fromJSON(steps.inputs.outputs.result).path }} CRAFT_PUBLISH_VERSION: ${{ fromJSON(steps.inputs.outputs.result).version }} + CRAFT_PUBLISH_WORKSPACE: ${{ fromJSON(steps.inputs.outputs.result).workspace || '' }} CRAFT_PUBLISH_TARGETS_JSON: ${{ toJSON(fromJSON(steps.inputs.outputs.result).targets) }} run: | # Render the "already published" JSON. @@ -209,22 +211,26 @@ jobs: # __repo__/` and Node's process.cwd() canonicalisation, # the cwd is `/github/workspace/__repo__` (root) or # `/github/workspace/__repo__/subdir/...` (monorepo). - case "$CRAFT_PUBLISH_PATH" in - .|./) container_cwd="/github/workspace/__repo__" ;; - ./*) container_cwd="/github/workspace/__repo__/${CRAFT_PUBLISH_PATH#./}" ;; - *) container_cwd="/github/workspace/__repo__/${CRAFT_PUBLISH_PATH}" ;; - esac - # Strip any trailing slash to match Node's canonicalisation. - container_cwd="${container_cwd%/}" + container_cwd="$(realpath -m "/github/workspace/__repo__/$CRAFT_PUBLISH_PATH")" + if [[ "$container_cwd" != "/github/workspace/__repo__" && "$container_cwd" != /github/workspace/__repo__/* ]]; then + echo "::error::Publish path must remain inside the target checkout." + exit 1 + fi cwd_hash="$(printf %s "$container_cwd" | sha1sum | cut -c1-12)" sanitise() { printf %s "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]\+/_/g; s/^_\+//; s/_\+$//'; } owner_sanitised="$(sanitise getsentry)" repo_sanitised="$(sanitise "$CRAFT_PUBLISH_REPO")" version_sanitised="$(sanitise "$CRAFT_PUBLISH_VERSION")" + workspace_prefix="" + if [[ -n "$CRAFT_PUBLISH_WORKSPACE" ]]; then + workspace_encoded="$(printf %s "$CRAFT_PUBLISH_WORKSPACE" | base64 -w 0 | tr '+/' '-_' | tr -d '=')" + workspace_prefix="workspace-${workspace_encoded}-" + fi state_dir="$GITHUB_WORKSPACE/.craft-state/craft" - state_file="$state_dir/publish-state-${owner_sanitised}-${repo_sanitised}-${cwd_hash}-${version_sanitised}.json" + state_file="$state_dir/publish-state-${owner_sanitised}-${repo_sanitised}-${cwd_hash}-${workspace_prefix}${version_sanitised}.json" mkdir -p "$state_dir" printf %s "$payload" > "$state_file" + echo "state_file=$state_file" >> "$GITHUB_OUTPUT" echo "Wrote state file: $state_file" - uses: docker://getsentry/craft:latest @@ -246,6 +252,10 @@ jobs: CRAFT_MERGE_TARGET: ${{ fromJSON(steps.inputs.outputs.result).merge_target }} CRAFT_LOG_LEVEL: ${{ vars.CRAFT_LOG_LEVEL || 'Info' }} CRAFT_DRY_RUN: ${{ fromJSON(steps.inputs.outputs.result).dry_run }} + CRAFT_WORKSPACE: ${{ fromJSON(steps.inputs.outputs.result).workspace || '' }} + # State must follow the issue checkout identity, not a workspace's + # optional GitHub release-repository override. + CRAFT_PUBLISH_STATE_GITHUB_REPO: getsentry/${{ fromJSON(steps.inputs.outputs.result).repo }} GIT_COMMITTER_NAME: sentry-release-bot[bot] GIT_AUTHOR_NAME: sentry-release-bot[bot] EMAIL: 180476844+sentry-release-bot[bot]@users.noreply.github.com @@ -287,6 +297,7 @@ jobs: if: ${{ cancelled() || failure() }} env: PUBLISH_ARGS: ${{ steps.inputs.outputs.result }} + CRAFT_STATE_FILE_PATH: ${{ steps.craft-state.outputs.state_file }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .__publish__/src/publish/update-issue.js diff --git a/src/modules/__tests__/details-from-context.js b/src/modules/__tests__/details-from-context.js index e6411c5..5b1ccdb 100644 --- a/src/modules/__tests__/details-from-context.js +++ b/src/modules/__tests__/details-from-context.js @@ -36,7 +36,7 @@ Assign the **accepted** label to this issue to approve the release. test("parse inputs", async () => { const result = await detailsFromContext(inputsArgs); - expect(result).toEqual({ + expect(result).toStrictEqual({ dry_run: "", merge_target: "custom-branch", path: ".", @@ -91,7 +91,7 @@ Assign the **accepted** label to this issue to approve the release. test("Do not extract merge_target value if its a default value", async () => { const result = await detailsFromContext(defaultTargetInputsArgs); - expect(result).toEqual({ + expect(result).toStrictEqual({ dry_run: "", merge_target: "", path: ".", @@ -101,6 +101,214 @@ test("Do not extract merge_target value if its a default value", async () => { }); }); +test("parses a human-readable workspace from the title", async () => { + const result = await detailsFromContext({ + context: { + repo: { owner: "getsentry", repo: "publish" }, + payload: { + issue: { + number: "123", + title: 'publish: getsentry/toolkit [workspace: "cli/v2"] @1.2.3', + body: "Requested by: @example", + labels: [], + }, + }, + }, + }); + + expect(result).toMatchObject({ + repo: "toolkit", + version: "1.2.3", + workspace: "cli/v2", + }); +}); + +test("parses escaped workspace characters from the title", async () => { + const result = await detailsFromContext({ + context: { + repo: { owner: "getsentry", repo: "publish" }, + payload: { + issue: { + number: "123", + title: + 'publish: getsentry/toolkit [workspace: "cli [preview] \\"next\\""] @1.2.3', + body: "Requested by: @example", + labels: [], + }, + }, + }, + }); + + expect(result.workspace).toBe('cli [preview] "next"'); +}); + +test("parses a safe Unicode workspace from the title", async () => { + const result = await detailsFromContext({ + context: { + repo: { owner: "getsentry", repo: "publish" }, + payload: { + issue: { + number: "123", + title: + 'publish: getsentry/toolkit [workspace: "cli-\u65e5\u672c\u8a9e"] @1.2.3', + body: "Requested by: @example", + labels: [], + }, + }, + }, + }); + + expect(result.workspace).toBe("cli-\u65e5\u672c\u8a9e"); +}); + +test("rejects a legacy title with an unexpected space before its version", async () => { + const fn = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: "publish: getsentry/toolkit @1.2.3", + body: "", + labels: [], + }, + }, + }, + }); + + await expect(fn).rejects.toThrow("Invalid publish issue title"); +}); + +test("rejects an empty or unsafe Unicode workspace", async () => { + const emptyWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: 'publish: getsentry/toolkit [workspace: ""] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const multilineWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\nnext"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const nulWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: 'publish: getsentry/toolkit [workspace: "\\u0000"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const tabWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\tnext"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const bidiWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\u202enext"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const lineSeparatorWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\u2028next"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + const paragraphSeparatorWorkspace = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\u2029next"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + + await expect(emptyWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(multilineWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(nulWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(tabWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(bidiWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(lineSeparatorWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); + await expect(paragraphSeparatorWorkspace).rejects.toThrow( + "Workspace names must be nonempty and cannot contain Unicode control, format, or separator characters" + ); +}); + +test("rejects a path that escapes the target checkout", async () => { + const fn = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: "publish: getsentry/toolkit/../other@1.2.3", + body: "", + labels: [], + }, + }, + }, + }); + + await expect(fn).rejects.toThrow("Invalid publish issue path"); +}); + test("throw error when context is missing the issue payload", async () => { const fn = () => detailsFromContext({ context: {} }); await expect(fn).rejects.toThrow("Issue context is not defined"); diff --git a/src/modules/__tests__/publish-workflow.js b/src/modules/__tests__/publish-workflow.js new file mode 100644 index 0000000..e29085c --- /dev/null +++ b/src/modules/__tests__/publish-workflow.js @@ -0,0 +1,82 @@ +import { spawnSync } from "child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { afterEach, expect, test } from "vitest"; + +const temporaryDirectories = []; + +function getSetTargetsScript() { + const workflow = readFileSync( + join(__dirname, "../../../.github/workflows/publish.yml"), + "utf8" + ); + const section = workflow.match( + / {6}- name: Set targets[\s\S]*? {8}run: \|\n(?