diff --git a/.github/workflows/ci-poller.yml b/.github/workflows/ci-poller.yml index a3db430..487e9da 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@v7 + with: + path: .__publish__ + - name: Check CI status for ci-pending issues env: # Use the sentry-internal-app token for label changes on this @@ -88,30 +93,23 @@ jobs: # Check each issue's CI status echo "$issues" | jq -c '.[]' | while read -r issue; do + ( number=$(echo "$issue" | jq -r '.number') 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." - continue + body_file=$(mktemp) + resolver_output_file="" + updated_body_file="" + trap 'for file in "${body_file:-}" "${resolver_output_file:-}" "${updated_body_file:-}"; do [[ -z "$file" ]] || rm -f "$file"; done' EXIT + printf '%s' "$issue" | jq -jr '.body' > "$body_file" + + if ! publish_input=$(PUBLISH_ISSUE_BODY_FILE="$body_file" 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." + exit 0 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,8 +147,19 @@ 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}}" - gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body "$updated_body" + resolver_output_file=$(mktemp) + updated_body_file=$(mktemp) + if ! PUBLISH_ISSUE_BODY_FILE="$body_file" PUBLISH_REVISION="$sha" PUBLISH_TITLE="$title" \ + node .__publish__/src/publish/resolve-ci-poller-input.js > "$resolver_output_file"; then + echo "::warning::Could not update canonical publish input for issue #${number}, skipping." + exit 0 + fi + if ! jq -ejr 'if (.issueBody | type) == "string" and (.issueBody | length) > 0 then .issueBody else error("Expected nonempty issueBody string") end' \ + "$resolver_output_file" > "$updated_body_file"; then + echo "::warning::Could not decode updated publish issue body for issue #${number}, skipping." + exit 0 + fi + gh issue edit "$number" -R "$GITHUB_REPOSITORY" --body-file "$updated_body_file" fi echo "Checking CI for ${repo}@${version} commit ${sha:0:8} (issue #${number})..." @@ -161,7 +170,7 @@ jobs: # installed on this repo) — don't try to derive state from partial data. if ! status_json=$(gh_api_release "repos/${repo}/commits/${sha}/status"); then echo " Could not fetch commit status for ${repo}@${sha:0:8}, skipping." - continue + exit 0 fi commit_status=$(echo "$status_json" | jq -r '.state') total_statuses=$(echo "$status_json" | jq -r '.total_count') @@ -171,7 +180,7 @@ jobs: # '.check_runs[]' and count with a second jq pass. if ! all_checks=$(gh_api_release --paginate "repos/${repo}/commits/${sha}/check-runs" --jq '.check_runs[]'); then echo " Could not fetch check runs for ${repo}@${sha:0:8}, skipping." - continue + exit 0 fi total_checks=$(echo "$all_checks" | jq -s 'length') @@ -187,7 +196,7 @@ jobs: # otherwise CI hasn't started yet and all counts would be 0. if [[ "$total_checks" == "0" && "$total_statuses" == "0" ]]; then echo " No check runs or commit statuses found — CI may not have started yet." - continue + exit 0 fi # CI is ready when: @@ -243,6 +252,7 @@ jobs: comment_body="CI **commit status** checks failed for ${repo}@${version} (\`${sha:0:8}\`). Publishing is blocked."$'\n\n'"Failed status checks:"$'\n'"${failed_contexts}"$'\n\n'"[View check runs](https://github.com/${repo}/commit/${sha}/checks/)"$'\n\n'"Re-add the **accepted** label once CI is fixed to retry." gh issue comment "$number" -R "$GITHUB_REPOSITORY" --body "$comment_body" fi + ) done # Cleanup: check if we should disable the poller. Runs even if the 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..2635ea6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -117,7 +117,7 @@ jobs: SENTRY_DSN: "https://303a687befb64dc2b40ce4c96de507c5@o1.ingest.sentry.io/6183838" steps: - name: Get repo contents - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: path: .__publish__ @@ -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 @@ -173,23 +156,32 @@ jobs: private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} owner: getsentry # create token that have access to all repos - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Check out target repo 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 }} + PUBLISH_REPOSITORY_DIRECTORY: __repo__ + run: node .__publish__/src/publish/discover-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 +201,31 @@ 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")" + version_component="$version_sanitised" + if [[ "$version_sanitised" != "$CRAFT_PUBLISH_VERSION" ]]; then + version_encoded="$(printf %s "$CRAFT_PUBLISH_VERSION" | base64 -w 0 | tr '+/' '-_' | tr -d '=')" + version_component="version-${version_encoded}" + fi + 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_component}.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 +236,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 +247,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 +292,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..9f9624e --- /dev/null +++ b/docs/publish-issue-format.md @@ -0,0 +1,107 @@ +# 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: `. The following canonical Peggy grammar is +generated from `src/modules/publish-issue-title.peggy`: + + +```peggy +// Canonical grammar for publish issue titles. A path suffix is syntactic only: +// the controller resolves the complete suffix as a workspace after checking +// out the CI-approved revision. +PublishIssueTitle + = "publish: " "getsentry/"? repo:Repository path:Path? "@" version:Version !. { + return { + repo, + path: path || "", + version, + }; + } + +Repository + = characters:RepositoryCharacter+ { return join(characters); } + +RepositoryCharacter + = [A-Za-z0-9_.-] + +Path + = segments:("/" segment:PathSegment { return `/${segment}`; })+ { return join(segments); } + +PathSegment + = characters:RepositoryCharacter+ { return join(characters); } + +Version + = characters:[A-Za-z0-9_.+-]+ { return join(characters); } +``` + + +New Craft requests always include the checkout repository identity. A workspace +release uses its full concrete path as the title suffix: + +```text +publish: getsentry/sentry@21.3.1 +publish: getsentry/toolkit/packages/cli@1.2.3 +``` + +Workspace paths in titles use ASCII path segments matching `[A-Za-z0-9_.-]+`, +except `.`, `..`, `__proto__`, and segments starting with `-`. Craft preserves +their exact spelling. + +Repository identities use the same safe token rule and cannot be `.`, `..`, +`__proto__`, or start with `-`. Versions must be valid Craft semantic versions; +build metadata such as `4.2.6+sentry1` is valid. + +The controller resolves the complete 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 concrete workspace paths. A +suffix that exactly matches one of those paths 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. + +`getsentry/` remains optional when parsing existing issues. Paths must use only +safe workspace segments. + +## 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 required. `(default)` means the target repository's default branch. +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 CI waiting. Publishing starts only when the CI poller +adds a fresh `ci-ready` label to an open, accepted issue with neither `ci-pending` +nor `ci-failed`. `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..2d0203a --- /dev/null +++ b/scripts/generate-publish-issue-title-parser.js @@ -0,0 +1,104 @@ +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 documentationPath = path.join( + __dirname, + "..", + "docs", + "publish-issue-format.md" +); +function generateDocumentation({ documentation, titleGrammar }) { + const markers = documentation.match( + /\n[\s\S]*?/.exec( + documentation + ); + + if (!marker) { + throw new Error("Could not find the generated title grammar in the documentation."); + } + + return documentation.replace( + marker[0], + `\n\`\`\`peggy\n${titleGrammar.trim()}\n\`\`\`\n` + ); +} + +function main() { + const grammar = fs.readFileSync(grammarPath, "utf8"); + const parser = peggy.generate(grammar, { + allowedStartRules: [ + "PublishIssueTitle", + "ReleaseRevision", + "CheckRunsLinkCount", + ], + format: "commonjs", + grammarSource: "publish-issue-title.peggy", + output: "source", + }); + + const generatedParser = prettier.format(`/* eslint-disable */\n${parser}`, { + filepath: outputPath, + }); + const titleGrammar = grammar.match( + /\/\/ BEGIN TITLE GRAMMAR\n(?[\s\S]*?)\/\/ END TITLE GRAMMAR/ + )?.groups?.grammar; + + if (!titleGrammar) { + throw new Error("Could not find the publish issue title grammar."); + } + + const documentation = fs.readFileSync(documentationPath, "utf8"); + const generatedDocumentation = generateDocumentation({ + documentation, + titleGrammar, + }); + + 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`." + ); + } + if (documentation !== generatedDocumentation) { + throw new Error( + "The generated publish issue title documentation is stale. Run `yarn generate`." + ); + } + } else { + fs.writeFileSync(outputPath, generatedParser); + fs.writeFileSync(documentationPath, generatedDocumentation); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { generateDocumentation }; diff --git a/src/modules/__tests__/ci-poller-input.js b/src/modules/__tests__/ci-poller-input.js new file mode 100644 index 0000000..140ccef --- /dev/null +++ b/src/modules/__tests__/ci-poller-input.js @@ -0,0 +1,97 @@ +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 root and full-path 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/packages/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/"); +}); + +test.each(["-toolkit", ".", "..", "__proto__"])( + "rejects unsafe repository identity before CI API calls: %s", + repo => { + expect(() => + getCiPollerInput({ + issueBody, + title: `publish: getsentry/${repo}@1.2.3`, + }) + ).toThrow("Invalid publish issue repository"); + } +); + +test.each(["--config", "1.2"])( + "rejects invalid release version before CI API calls: %s", + version => { + expect(() => + getCiPollerInput({ + issueBody, + title: `publish: getsentry/toolkit@${version}`, + }) + ).toThrow("Invalid publish issue version"); + } +); + +test.each(["publish: getsentry/@1.2.3", "publish: getsentry/toolkit$@1.2.3"])( + "rejects malformed repository identity before CI API calls: %s", + title => { + expect(() => getCiPollerInput({ issueBody, title })).toThrow( + "Invalid publish issue title" + ); + } +); + +test.each([ + "publish: getsentry/toolkit/../other@1.2.3", + "publish: getsentry/toolkit/./other@1.2.3", + "publish: getsentry/toolkit/__proto__/other@1.2.3", + "publish: getsentry/toolkit/--config@1.2.3", +])("rejects unsafe publish path before CI API calls: %s", title => { + expect(() => getCiPollerInput({ issueBody, title })).toThrow( + "Invalid publish issue path" + ); +}); diff --git a/src/modules/__tests__/ci-poller-workflow.js b/src/modules/__tests__/ci-poller-workflow.js new file mode 100644 index 0000000..c859f5a --- /dev/null +++ b/src/modules/__tests__/ci-poller-workflow.js @@ -0,0 +1,197 @@ +import { + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { spawnSync } from "child_process"; + +import { afterEach, expect, test } from "vitest"; + +const temporaryDirectories = []; +const INITIAL_REVISION = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const UPDATED_REVISION = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function getPollerScript() { + const workflow = readFileSync( + join(__dirname, "../../../.github/workflows/ci-poller.yml"), + "utf8" + ); + const section = workflow.match( + / {6}- name: Check CI status for ci-pending issues[\s\S]*? {8}run: \|\n(?