diff --git a/.github/workflows/ci-poller.yml b/.github/workflows/ci-poller.yml index a3db430..324b63f 100644 --- a/.github/workflows/ci-poller.yml +++ b/.github/workflows/ci-poller.yml @@ -48,6 +48,11 @@ jobs: private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} owner: getsentry # create token with access to all getsentry repos + - name: Check out publish controller + uses: actions/checkout@v5 + with: + path: .__publish__ + - name: Check CI status for ci-pending issues env: # Use the sentry-internal-app token for label changes on this @@ -92,26 +97,14 @@ jobs: title=$(echo "$issue" | jq -r '.title') body=$(echo "$issue" | jq -r '.body') - # Parse repo and version from title: "publish: owner/repo[/path]@version" - # Only take owner/repo (first two segments) — monorepos like - # "getsentry/relay/py@0.9.26" have a path suffix that isn't part - # of the GitHub repo name. - repo=$(echo "$title" | sed -n 's|^publish: \([^/]*/[^/@]*\).*@.*|\1|p') - version=$(echo "$title" | sed -n 's/^publish: .*@\(.*\)/\1/p') - - if [[ -z "$repo" || -z "$version" ]]; then - echo "::warning::Could not parse repo/version from issue #${number}: ${title}" - continue - fi - - # Extract the commit SHA from the "View check runs" link in the issue body. - # Link format: https://github.com/{owner}/{repo}/commit/{SHA}/checks/ - issue_sha=$(echo "$body" | grep -oP '(?<=commit/)[0-9a-f]{40}(?=/checks)' || true) - - if [[ -z "$issue_sha" ]]; then - echo "::warning::Could not extract commit SHA from issue #${number} body, skipping." + if ! publish_input=$(PUBLISH_ISSUE_BODY="$body" PUBLISH_TITLE="$title" \ + node .__publish__/src/publish/resolve-ci-poller-input.js); then + echo "::warning::Could not parse canonical publish input for issue #${number}, skipping." continue fi + repo=$(echo "$publish_input" | jq -r '.repo') + version=$(echo "$publish_input" | jq -r '.version') + issue_sha=$(echo "$publish_input" | jq -r '.revision') # Resolve the release branch name from the original commit's check # suites (avoids hard-coding "release/{version}" since repos can @@ -149,7 +142,8 @@ jobs: # for humans and for subsequent poller runs. if [[ "$sha" != "$issue_sha" ]]; then echo " Branch ${branch} moved: ${issue_sha:0:8} → ${sha:0:8}. Updating issue." - updated_body="${body//${issue_sha}/${sha}}" + updated_body=$(PUBLISH_ISSUE_BODY="$body" PUBLISH_REVISION="$sha" PUBLISH_TITLE="$title" \ + node .__publish__/src/publish/resolve-ci-poller-input.js | jq -r '.issueBody') gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body "$updated_body" fi diff --git a/.github/workflows/cocoapods-keepalive.yml b/.github/workflows/cocoapods-keepalive.yml index 21c0696..9c11b0e 100644 --- a/.github/workflows/cocoapods-keepalive.yml +++ b/.github/workflows/cocoapods-keepalive.yml @@ -1,4 +1,4 @@ -name: "CocoaPods Token Keep-Alive" +name: CocoaPods Token Keep-Alive on: schedule: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cff6bc3..f6dc96a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,36 +135,19 @@ jobs: id: inputs run: node .__publish__/src/publish/inputs.js + - name: Resolve CI-approved release revision + id: release-revision + env: + PUBLISH_ARGS: ${{ steps.inputs.outputs.result }} + PUBLISH_ISSUE_BODY: ${{ github.event.issue.body }} + run: node .__publish__/src/publish/resolve-release-revision.js + - name: Inform start - if: steps.inputs.outcome == 'success' + if: steps.inputs.outcome == 'success' && steps.release-revision.outcome == 'success' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .__publish__/src/publish/post-workflow-details.js - # Setting the target repo branch will cause the craft config (.craft.yml) to be taken from the checked out branch - # By default, we check out the default branch of the repo. - # If you need to maintain diverging craft configs on different branches, add your repo and the merge target branch - # (i.e. the branch craft will merge the release branch into) into the if condition below. - - name: Set target repo checkout branch - # Note: Branches registered here MUST BE protected in the target repo! - if: | - fromJSON(steps.inputs.outputs.result).repo == 'sentry-migr8' && fromJSON(steps.inputs.outputs.result).merge_target == 'tmp-merge-target' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v10' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v9' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v8' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'v7' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-javascript' && fromJSON(steps.inputs.outputs.result).merge_target == 'master' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-python' && fromJSON(steps.inputs.outputs.result).merge_target == 'alpha' || - fromJSON(steps.inputs.outputs.result).repo == 'sentry-wizard' && fromJSON(steps.inputs.outputs.result).merge_target == '1.x' || - false - id: target-repo-branch - env: - MERGE_TARGET: ${{ fromJSON(steps.inputs.outputs.result).merge_target }} - REPO: ${{ fromJSON(steps.inputs.outputs.result).repo }} - run: | - echo "taking craft config from branch \"$MERGE_TARGET\" in \"$REPO\"" - echo "target_repo_branch=$MERGE_TARGET" >> "$GITHUB_OUTPUT" - - name: Get Release Bot auth token id: token uses: actions/create-github-app-token@v3 @@ -178,18 +161,40 @@ jobs: if: ${{ steps.inputs.outputs.result }} with: path: __repo__ - ref: ${{ steps.target-repo-branch.outputs.target_repo_branch || ''}} + ref: ${{ steps.release-revision.outputs.revision }} repository: getsentry/${{ fromJSON(steps.inputs.outputs.result).repo }} token: ${{ steps.token.outputs.token }} fetch-depth: 0 + - name: Resolve publish location + id: location + env: + PUBLISH_ARGS: ${{ steps.inputs.outputs.result }} + run: | + workspace_names='[]' + requires_workspace_discovery="$(node -e ' + const { needsWorkspaceDiscovery } = require("./.__publish__/src/modules/publish-location"); + const input = JSON.parse(process.env.PUBLISH_ARGS || ""); + process.stdout.write(String(needsWorkspaceDiscovery(input))); + ')" + if [[ "$requires_workspace_discovery" == "true" && -f __repo__/.craft.yml ]]; then + workspace_names="$(docker run --rm \ + --volume "$GITHUB_WORKSPACE/__repo__:/github/workspace/__repo__" \ + --workdir /github/workspace/__repo__ \ + getsentry/craft:latest workspace list)" + fi + CRAFT_WORKSPACE_NAMES="$workspace_names" \ + node .__publish__/src/publish/resolve-location.js + - 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_PATH: ${{ fromJSON(steps.location.outputs.result).path }} CRAFT_PUBLISH_VERSION: ${{ fromJSON(steps.inputs.outputs.result).version }} + CRAFT_PUBLISH_WORKSPACE: ${{ fromJSON(steps.location.outputs.result).workspace || '' }} CRAFT_PUBLISH_TARGETS_JSON: ${{ toJSON(fromJSON(steps.inputs.outputs.result).targets) }} run: | # Render the "already published" JSON. @@ -209,22 +214,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 @@ -235,8 +244,8 @@ jobs: -e -c " export HOME=/root && - cd __repo__/${{ fromJSON(steps.inputs.outputs.result).path }} && - exec craft publish ${{ fromJSON(steps.inputs.outputs.result).version }} + cd __repo__/${{ fromJSON(steps.location.outputs.result).path }} && + exec craft publish ${{ fromJSON(steps.inputs.outputs.result).version }} --rev ${{ steps.release-revision.outputs.revision }} " env: # Pin Craft's publish-state directory to a path outside @@ -246,6 +255,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.location.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 +300,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/README.md b/README.md index b8e07f0..4cbc9b9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ flowchart TD 1. Observe the issue for information about the triggered run 1. The issue will automatically be closed when publishing succeeds +## Publish Issue Format + +The release workflow creates publish requests with a stable title and body contract. See +[Publish Issue Format](docs/publish-issue-format.md) for the accepted syntax and fields. + ## CalVer To enable calendar versioning, add the following to your `.craft.yml`: diff --git a/docs/publish-issue-format.md b/docs/publish-issue-format.md new file mode 100644 index 0000000..3092030 --- /dev/null +++ b/docs/publish-issue-format.md @@ -0,0 +1,96 @@ +# Publish Issue Format + +The Publish workflow treats a publish issue as a release request. The title identifies +the release; the body supplies the merge target and selected targets. Do not edit the +title by hand unless it remains valid under this format. + +## Title + +Every title starts with `publish: `. This EBNF is canonical: + +```text +title = "publish: ", [ "getsentry/" ], repository, [ path ], + [ legacy-workspace ], "@", version ; +repository = token, { token } ; +path = "/", path-segment, { "/", path-segment } ; +path-segment = token, { token } ; +legacy-workspace = " [workspace: ", json-string, "] " ; +version = version-character, { version-character } ; +token = ? ASCII letter, digit, ".", "_", or "-" ? ; +version-character = token | "+" ; +``` + +New Craft requests always include the checkout repository identity. Root workspace +releases use one trailing path segment for the workspace name: + +```text +publish: getsentry/sentry@21.3.1 +publish: getsentry/toolkit/cli@1.2.3 +``` + +Craft rejects a workspace with a non-root checkout path. Workspace names in new titles +must match `^[A-Za-z0-9_.-]+$`; Craft preserves their exact spelling. + +The controller resolves a one-segment suffix only after it checks out the exact +CI-approved revision from the `View check runs` link. When that checkout has a root +`.craft.yml`, `craft workspace list` supplies the exact workspace keys. A suffix that +exactly matches one of those keys is a workspace; every other suffix remains a checkout +path. The controller never normalizes names. A missing root `.craft.yml` always means +checkout-path behavior. Discovery errors with a root configuration fail the release. + +Existing JSON-qualified workspace titles remain supported for compatibility, but Craft +does not create them: + +```text +publish: getsentry/toolkit [workspace: "cli/v2"] @1.2.3 +publish: getsentry/toolkit [workspace: "cli [preview] \"next\""] @1.2.3 +``` + +Legacy workspace titles must also use the repository root path. + +`` is one valid JSON string, including its double quotes. It must decode +to a nonempty workspace name and must not contain Unicode control (`Cc`), format +(`Cf`), line-separator (`Zl`), or paragraph-separator (`Zp`) characters. Legacy +workspace titles have one space after `]` before `@`; unqualified titles have no space +before `@`. + +`getsentry/` remains optional when parsing existing issues. Paths must not contain a +`..` segment. + +## Body + +The request must start with these body fields: + +```markdown +Requested by: @ + +Merge target: + +Quick links: + +- [View changes](compare-url) +- [View check runs](checks-url) + +Assign the **accepted** label to this issue to approve the release. + +### Targets + +- [ ] + +Checked targets will be skipped (either already published or user-requested skip). Uncheck to retry a target. +``` + +`Merge target` is optional. `(default)` means the target repository's default branch. +When present, the branch may contain letters, digits, `_`, `.`, `/`, and `-`. + +The workflow reads checked entries (`- [x] `) in the `### Targets` section. +It preserves checked entries when Craft refreshes an existing request. During a failed +release, the controller updates target checkboxes from the secure Craft publish-state +file; targets marked checked are skipped on retry unless manually unchecked. + +The requester, approval guidance, and optional changelog section are informational. The +`Quick links` must follow `Requested by` and `Merge target`, and contain exactly one `View +changes` line followed by exactly one `View check runs` line for the checkout repository. +The controller and CI poller use that check-runs revision as the release authority. The +`accepted` label starts publishing; +`dry-run` requests dry-run mode. diff --git a/package.json b/package.json index d59a8b6..0790379 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "undici": "^6.23.0" }, "scripts": { - "test": "vitest run", + "generate": "node scripts/generate-publish-issue-title-parser.js", + "check:generated": "node scripts/generate-publish-issue-title-parser.js --check", + "test": "yarn check:generated && vitest run", "test:watch": "vitest", "lint": "eslint src .github --ignore-pattern '!.github'", "prettier": "prettier --write src" @@ -24,6 +26,7 @@ "eslint": "^8.9.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-yml": "^0.13.0", + "peggy": "5.1.0", "prettier": "^2.2.1", "vitest": "^4.1.0" }, diff --git a/scripts/generate-publish-issue-title-parser.js b/scripts/generate-publish-issue-title-parser.js new file mode 100644 index 0000000..b812306 --- /dev/null +++ b/scripts/generate-publish-issue-title-parser.js @@ -0,0 +1,40 @@ +const fs = require("fs"); +const path = require("path"); +const peggy = require("peggy"); +const prettier = require("prettier"); + +const grammarPath = path.join( + __dirname, + "..", + "src", + "modules", + "publish-issue-title.peggy" +); +const outputPath = path.join( + __dirname, + "..", + "src", + "modules", + "publish-issue-title.js" +); +const grammar = fs.readFileSync(grammarPath, "utf8"); +const parser = peggy.generate(grammar, { + format: "commonjs", + grammarSource: "publish-issue-title.peggy", + output: "source", +}); + +const generatedParser = prettier.format(`/* eslint-disable */\n${parser}`, { + filepath: outputPath, +}); + +if (process.argv.includes("--check")) { + const currentParser = fs.readFileSync(outputPath, "utf8"); + if (currentParser !== generatedParser) { + throw new Error( + "The generated publish issue title parser is stale. Run `yarn generate`." + ); + } +} else { + fs.writeFileSync(outputPath, generatedParser); +} diff --git a/src/modules/__tests__/ci-poller-input.js b/src/modules/__tests__/ci-poller-input.js new file mode 100644 index 0000000..8c80856 --- /dev/null +++ b/src/modules/__tests__/ci-poller-input.js @@ -0,0 +1,53 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { expect, test } from "vitest"; + +const { getCiPollerInput } = require("../ci-poller-input.js"); + +const REVISION = "7e5ca7ed5581552de066e2a8bc295b8306be38ac"; +const issueBody = `Requested by: @byk + +Merge target: (default) + +Quick links: +- [View changes](https://github.com/getsentry/toolkit/compare/1.2.2...release/1.2.3) +- [View check runs](https://github.com/getsentry/toolkit/commit/${REVISION}/checks/)`; + +test("parses compact and legacy workspace publish titles", () => { + expect( + getCiPollerInput({ + issueBody, + title: "publish: getsentry/toolkit/cli@1.2.3", + }) + ).toEqual({ + repo: "getsentry/toolkit", + revision: REVISION, + version: "1.2.3", + }); + + expect( + getCiPollerInput({ + issueBody, + title: 'publish: toolkit [workspace: "cli/v2"] @1.2.3', + }) + ).toEqual({ + repo: "getsentry/toolkit", + revision: REVISION, + version: "1.2.3", + }); +}); + +test("uses the shared resolver in the CI poller", () => { + const workflow = readFileSync( + join(__dirname, "../../../.github/workflows/ci-poller.yml"), + "utf8" + ); + + expect(workflow).toContain("name: Check out publish controller"); + expect(workflow).toContain("path: .__publish__"); + expect(workflow).toContain( + "node .__publish__/src/publish/resolve-ci-poller-input.js" + ); + expect(workflow).not.toContain("grep -oP '(?<=commit/"); +}); diff --git a/src/modules/__tests__/details-from-context.js b/src/modules/__tests__/details-from-context.js index e6411c5..d75c587 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,252 @@ 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("rejects a legacy workspace with a non-root path", async () => { + const fn = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit/packages/cli [workspace: "cli/v2"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + + await expect(fn).rejects.toThrow( + "A publish workspace must use the repository root path." + ); +}); + +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 invalid JSON workspace escape with a clear error", async () => { + const fn = () => + detailsFromContext({ + context: { + payload: { + issue: { + title: + 'publish: getsentry/toolkit [workspace: "cli\\qnext"] @1.2.3', + body: "", + labels: [], + }, + }, + }, + }); + + await expect(fn).rejects.toThrow("Invalid publish workspace JSON in 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-location.js b/src/modules/__tests__/publish-location.js new file mode 100644 index 0000000..b75192f --- /dev/null +++ b/src/modules/__tests__/publish-location.js @@ -0,0 +1,99 @@ +import { expect, test } from "vitest"; + +const { + needsWorkspaceDiscovery, + resolvePublishLocation, +} = require("../publish-location.js"); + +test.each([ + [{ path: "./cli" }, true], + [{ path: "./packages/cli" }, false], + [{ path: "." }, false], + [{ path: "./cli", workspace: "cli" }, false], +])("workspace discovery is %s for %j", (input, expected) => { + expect(needsWorkspaceDiscovery(input)).toBe(expected); +}); + +test("classifies an exact one-segment workspace without normalizing it", () => { + expect( + resolvePublishLocation({ + path: "./CLI", + workspaceNames: ["cli", "CLI"], + }) + ).toStrictEqual({ path: ".", workspace: "CLI" }); +}); + +test("keeps a non-workspace suffix as a checkout path", () => { + expect( + resolvePublishLocation({ + path: "./packages", + workspaceNames: ["cli"], + }) + ).toStrictEqual({ path: "./packages" }); +}); + +test("keeps multi-segment paths even when the last segment is a workspace", () => { + expect( + resolvePublishLocation({ + path: "./packages/cli", + workspaceNames: ["cli"], + }) + ).toStrictEqual({ path: "./packages/cli" }); +}); + +test("keeps root releases at the checkout root", () => { + expect( + resolvePublishLocation({ + path: ".", + workspaceNames: ["cli"], + }) + ).toStrictEqual({ path: "." }); +}); + +test("preserves the legacy explicit workspace", () => { + expect( + resolvePublishLocation({ + path: ".", + workspace: "cli/v2", + workspaceNames: [], + }) + ).toStrictEqual({ path: ".", workspace: "cli/v2" }); +}); + +test("rejects a workspace with a non-root path", () => { + expect(() => + resolvePublishLocation({ + path: "./packages/cli", + workspace: "cli/v2", + workspaceNames: [], + }) + ).toThrow("A publish workspace must use the repository root path."); +}); + +test("does not validate discovery for a legacy explicit workspace", () => { + expect( + resolvePublishLocation({ + path: ".", + workspace: "cli/v2", + workspaceNames: ["invalid/workspace"], + }) + ).toStrictEqual({ path: ".", workspace: "cli/v2" }); +}); + +test("rejects an invalid workspace returned by discovery", () => { + expect(() => + resolvePublishLocation({ + path: "./cli", + workspaceNames: ["cli-日本語"], + }) + ).toThrow("Craft workspace discovery returned an invalid workspace list"); +}); + +test.each([".", ".."])("rejects traversal workspace name %s", (workspace) => { + expect(() => + resolvePublishLocation({ + path: `./${workspace}`, + workspaceNames: [workspace], + }) + ).toThrow("Craft workspace discovery returned an invalid workspace list."); +}); diff --git a/src/modules/__tests__/publish-workflow.js b/src/modules/__tests__/publish-workflow.js new file mode 100644 index 0000000..7409e0d --- /dev/null +++ b/src/modules/__tests__/publish-workflow.js @@ -0,0 +1,126 @@ +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 { resolvePublishLocation } = require("../publish-location.js"); + +const temporaryDirectories = []; + +function getWorkflow() { + return readFileSync( + join(__dirname, "../../../.github/workflows/publish.yml"), + "utf8" + ); +} + +function getSetTargetsScript() { + const workflow = getWorkflow(); + const section = workflow.match( + / {6}- name: Set targets[\s\S]*? {8}run: \|\n(?