From baf4791b3f2b544bcb3ea5d1f7b19dcfc4d35ccb Mon Sep 17 00:00:00 2001 From: Pascal Garber Date: Mon, 24 Aug 2026 09:17:11 +0200 Subject: [PATCH 1/2] Examples: fix build generating nothing under GJS The examples build spawned `buildExamples()` without awaiting it. Under GJS the process ends when the module's synchronous body does, so the promise chain was torn down after the first directory: no index.ts was written, examples.ts was never regenerated, and the script still exited 0. Both CI and the issue-to-PR workflow therefore "built" the examples while producing nothing. Await the build at the top level, and while here fix two defects it hid: - slugToCamelCase only un-dashed `-[a-z]`, so a slug with a digit after a dash ('line-buster-6502') generated the identifier 'lineBuster-6502', which does not parse. - the directory list came straight from readdir, making the committed examples.ts order filesystem-dependent. Claude-Session: https://claude.ai/code/session_01FrdNDUY1rJWBJsDU1KAwKj --- packages/examples/build.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/examples/build.ts b/packages/examples/build.ts index 853e4433..d4a3a5cf 100644 --- a/packages/examples/build.ts +++ b/packages/examples/build.ts @@ -9,12 +9,18 @@ import path from "node:path"; const __dirname = process.cwd(); /** - * Convert a slug to camelCase + * Convert a slug to a camelCase JavaScript identifier * @param slug - The slug to convert (e.g., "commented-snake") * @returns The camelCase version (e.g., "commentedSnake") + * + * Every dash has to go, not just the ones before a letter: slugs may contain + * digits, and `-([a-z])` left "line-buster-6502" as "lineBuster-6502" — an + * identifier the generated index.ts cannot even parse. A slug may also start + * with a digit, which no identifier can, so prefix those. */ function slugToCamelCase(slug: string): string { - return slug.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + const camelCase = slug.replace(/-+([a-z0-9])/g, (_, character: string) => character.toUpperCase()); + return /^[0-9]/.test(camelCase) ? `example${camelCase[0].toUpperCase()}${camelCase.slice(1)}` : camelCase; } /** @@ -29,7 +35,10 @@ async function findExampleDirectories(): Promise { entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== "dist" ) .map((entry) => entry.name); - return directories; + // Sorted, because readdir order is filesystem-dependent and this output is + // committed: without it the generated examples.ts reorders itself per machine + // and the examples show up in a different order in the app. + return directories.sort(); } /** @@ -165,8 +174,15 @@ async function buildExamples(): Promise { console.log(` Skipped: ${skipped}`); } -// Run the build -buildExamples().catch((error) => { +// Run the build. This MUST be awaited at the top level: under GJS the module's +// synchronous body finishing is what ends the process, so a fire-and-forget +// `buildExamples().catch(...)` was torn down mid-chain — it printed the first +// "Processing directory" line, wrote no index.ts, regenerated no examples.ts, +// and still exited 0. Top-level await keeps module evaluation pending until the +// build is actually done. +try { + await buildExamples(); +} catch (error) { console.error("Build failed:", error); process.exit(1); -}); +} From 52ce4f1e91103a98529f8403d3a0653f69e0f176 Mon Sep 17 00:00:00 2001 From: Pascal Garber Date: Mon, 24 Aug 2026 09:17:23 +0200 Subject: [PATCH 2/2] Ci: repair the example issue-to-PR workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every example submitted since the yarn.lock was dropped (#144) failed. The workflow still ran `yarn install --immutable` + `yarn workspace ... build` on a plain ubuntu runner, so yarn 1 silently wrote a fresh lockfile (which `git add -A` would have committed) and the examples build died on `spawn gjs ENOENT` — ubuntu-latest ships no gjs. - Run in the fedora:43 container the CI type-check job uses, and install with `gjsify install --immutable` per gjsify-lock.json. Build steps use the workspace-pinned CLI, not the floating bootstrap copy. - Parse and validate the submission in .github/scripts/example-from-issue.mjs instead of shell + python json escaping: slug, license, the 2048-char displayMemory, an unused slug, and proper TS string escaping. - Gate on the example actually working: it must assemble with the headless `learn6502` CLI, land in examples.ts, and type-check against app-web. - Add workflow_dispatch(issue) so a submission can be retried — the issues event fires once, which is why the three open submissions were stranded. - Report the reason back on the issue when a submission is rejected, instead of leaving the contributor with silence. - Stage explicit paths rather than `git add -A`, and pass every untrusted value through env instead of splicing it into shell or JS. Claude-Session: https://claude.ai/code/session_01FrdNDUY1rJWBJsDU1KAwKj --- .github/scripts/example-from-issue.mjs | 196 +++++++++++++++ .github/workflows/issue-to-pr.yml | 322 +++++++++++++++---------- 2 files changed, 395 insertions(+), 123 deletions(-) create mode 100644 .github/scripts/example-from-issue.mjs diff --git a/.github/scripts/example-from-issue.mjs b/.github/scripts/example-from-issue.mjs new file mode 100644 index 00000000..f5b65603 --- /dev/null +++ b/.github/scripts/example-from-issue.mjs @@ -0,0 +1,196 @@ +/** + * Turn a `[example]` submission issue into the two files an example is made of: + * `packages/examples//.asm` and `.meta.ts`. + * + * The issue body is written by the app's share dialog (see + * `packages/app-gnome/src/widgets/share-dialog.ts`) — a ```json metadata block + * plus an ```assembly code block. Contributors can also paste that body by hand + * when the prefilled GitHub URL exceeds GitHub's length limit, so treat every + * field as untrusted input and validate it here rather than in shell. + * + * Usage: node .github/scripts/example-from-issue.mjs [repo-root] + * + * On success the rendered paths are appended to $GITHUB_OUTPUT (slug, base_dir, + * code_path, meta_path). On failure the reason is written to + * $EXAMPLE_ERROR_FILE — the workflow posts it back to the issue — and the + * process exits non-zero. Both that file and the issue body live outside the + * work tree so a failed run leaves nothing to accidentally commit. + */ +import fs from "node:fs"; +import path from "node:path"; + +const [, , bodyFile, repoRootArg] = process.argv; +const repoRoot = repoRootArg ?? process.cwd(); + +/** Fields the app fills in and `ExampleMetaJson` requires. */ +const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +/** `license` is a string-literal type in `example-meta.ts`; anything else fails `gjsify tsc`. */ +const ALLOWED_LICENSES = ["CC-BY-4.0"]; +/** The display is 32x32 cells, one hex byte each — the app snapshots it as 2048 hex chars. */ +const DISPLAY_MEMORY_RE = /^[0-9a-fA-F]{2048}$/; +const GITHUB_USERNAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +class SubmissionError extends Error {} + +function fail(message) { + throw new SubmissionError(message); +} + +/** + * Read a fenced block by its info string. Written by hand by some contributors, + * so tolerate `” ```asm ”` and trailing whitespace after the fence. + */ +function readFencedBlock(body, languages) { + for (const language of languages) { + const match = body.match(new RegExp("```" + language + "[ \\t]*\\r?\\n([\\s\\S]*?)```")); + if (match) return match[1]; + } + return null; +} + +function requireString(metadata, field, { maxLength = 200 } = {}) { + const value = metadata[field]; + if (typeof value !== "string" || value.trim() === "") { + fail(`\`${field}\` is missing or empty in the metadata block.`); + } + if (value.length > maxLength) { + fail(`\`${field}\` is longer than ${maxLength} characters.`); + } + return value.trim(); +} + +/** Emit a TypeScript string literal — the values come from an issue, so escape properly. */ +function tsString(value) { + return JSON.stringify(value); +} + +function renderMeta(metadata) { + const lines = [ + 'import { _ } from "@learn6502/core";', + 'import type { ExampleMetaJson } from "../example-meta.ts";', + "export default {", + ` slug: ${tsString(metadata.slug)},`, + ` // TRANSLATORS: Example title for ${metadata.title}`, + ` title: _(${tsString(metadata.title)}),`, + ` // TRANSLATORS: Example description for ${metadata.title}`, + ` description: _(${tsString(metadata.description)}),`, + ` author: ${tsString(metadata.author)},`, + ` license: ${tsString(metadata.license)},`, + ` displayMemory: ${tsString(metadata.displayMemory)},`, + ]; + if (metadata.sourceUrl) lines.push(` sourceUrl: ${tsString(metadata.sourceUrl)},`); + if (metadata.githubUsername) lines.push(` githubUsername: ${tsString(metadata.githubUsername)},`); + lines.push("} as ExampleMetaJson;", ""); + return lines.join("\n"); +} + +function parseSubmission(body) { + const metadataBlock = readFencedBlock(body, ["json"]); + if (metadataBlock === null) { + fail( + "No ```json metadata block found. If the app told you the example was too large to insert automatically, " + + "paste the content it copied to your clipboard over the placeholder text and try again." + ); + } + + let metadata; + try { + metadata = JSON.parse(metadataBlock); + } catch (error) { + fail("The ```json metadata block is not valid JSON: " + error.message); + } + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { + fail("The ```json metadata block must contain a JSON object."); + } + + const codeBlock = readFencedBlock(body, ["assembly", "asm", "6502"]); + if (codeBlock === null) { + fail("No ```assembly code block found."); + } + // Keep the source verbatim apart from the fence's own line breaks: the + // comment art and indentation are part of what makes an example readable. + const code = codeBlock.replace(/^\r?\n/, "").replace(/\s+$/, "") + "\n"; + if (code.trim() === "") { + fail("The ```assembly code block is empty."); + } + + const slug = requireString(metadata, "slug", { maxLength: 64 }); + if (!SLUG_RE.test(slug)) { + fail(`\`slug\` must be lowercase letters, digits and single dashes (got \`${slug}\`).`); + } + + const license = requireString(metadata, "license", { maxLength: 32 }); + if (!ALLOWED_LICENSES.includes(license)) { + fail(`\`license\` must be one of ${ALLOWED_LICENSES.join(", ")} (got \`${license}\`).`); + } + + const displayMemory = requireString(metadata, "displayMemory", { maxLength: 4096 }); + if (!DISPLAY_MEMORY_RE.test(displayMemory)) { + fail("`displayMemory` must be exactly 2048 hex characters (a 32x32 display snapshot)."); + } + + const sourceUrl = typeof metadata.sourceUrl === "string" ? metadata.sourceUrl.trim() : ""; + if (sourceUrl && !/^https:\/\/[^\s"']+$/.test(sourceUrl)) { + fail("`sourceUrl` must be an https URL."); + } + + const githubUsername = typeof metadata.githubUsername === "string" ? metadata.githubUsername.trim() : ""; + if (githubUsername && !GITHUB_USERNAME_RE.test(githubUsername)) { + fail(`\`githubUsername\` is not a valid GitHub username (got \`${githubUsername}\`).`); + } + + return { + slug, + title: requireString(metadata, "title"), + description: requireString(metadata, "description", { maxLength: 500 }), + author: requireString(metadata, "author", { maxLength: 100 }), + license, + displayMemory: displayMemory.toLowerCase(), + sourceUrl, + githubUsername, + code, + }; +} + +function main() { + if (!bodyFile) fail("Internal error: no issue body file given."); + const body = fs.readFileSync(bodyFile, "utf8"); + const submission = parseSubmission(body); + + const baseDir = path.join("packages", "examples", submission.slug); + const absoluteBaseDir = path.join(repoRoot, baseDir); + if (fs.existsSync(absoluteBaseDir)) { + fail( + `An example with the slug \`${submission.slug}\` already exists (\`${baseDir}\`). ` + + "Pick a different slug, or open a pull request against the existing example." + ); + } + + const codePath = path.join(baseDir, `${submission.slug}.asm`); + const metaPath = path.join(baseDir, `${submission.slug}.meta.ts`); + fs.mkdirSync(absoluteBaseDir, { recursive: true }); + fs.writeFileSync(path.join(repoRoot, codePath), submission.code, "utf8"); + fs.writeFileSync(path.join(repoRoot, metaPath), renderMeta(submission), "utf8"); + + const outputs = { + slug: submission.slug, + base_dir: baseDir, + code_path: codePath, + meta_path: metaPath, + }; + for (const [key, value] of Object.entries(outputs)) { + console.log(`${key}=${value}`); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); + } + } +} + +try { + main(); +} catch (error) { + const message = error instanceof SubmissionError ? error.message : `Unexpected error: ${error.stack}`; + fs.writeFileSync(process.env.EXAMPLE_ERROR_FILE ?? path.join(repoRoot, "example-error.txt"), message, "utf8"); + console.error(message); + process.exit(1); +} \ No newline at end of file diff --git a/.github/workflows/issue-to-pr.yml b/.github/workflows/issue-to-pr.yml index fcde8bde..b8377e10 100644 --- a/.github/workflows/issue-to-pr.yml +++ b/.github/workflows/issue-to-pr.yml @@ -2,108 +2,81 @@ name: "Issue → PR: Add Example" on: issues: - types: [opened] + types: [opened, reopened] + # Lets a submission be retried after a failed run — the issue event only ever + # fires once, so without this a broken workflow strands every example that was + # submitted while it was broken. + workflow_dispatch: + inputs: + issue: + description: "Number of the [example] issue to convert into a pull request" + required: true + type: string permissions: contents: write pull-requests: write issues: write +concurrency: + group: issue-to-pr-${{ github.event.issue.number || inputs.issue }} + cancel-in-progress: false + jobs: make-pr: - if: startsWith(github.event.issue.title, '[example]') + # The `issues` event fires for every new issue; only submissions are ours. + # A manual dispatch always runs — that is how an issue gets retried. + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.issue.title, '[example]') runs-on: ubuntu-latest + # `@learn6502/examples`' build script and `gjsify tsc` are bundled for GJS + # and spawned via `gjs`, which ubuntu-latest does not ship (the old plain + # runner failed here with `spawn gjs ENOENT`). Same Fedora 43 container the + # CI type-check job uses: GJS 1.86 / SpiderMonkey 140, the gjsify target. + container: + image: fedora:43 + steps: - - uses: actions/checkout@v6 + - name: Install container prerequisites (incl. gjs) + run: dnf install -y git tar xz findutils gjs - - name: Extract metadata and code from issue body - id: extract - run: | - set -euo pipefail - node -e ' - const fs = require("fs"); - const body = process.env.ISSUE_BODY; - - // Extract JSON metadata - const jsonMatch = body.match(/```json\s*([\s\S]*?)```/); - if (!jsonMatch) { console.error("JSON block not found"); process.exit(1); } - const metadata = JSON.parse(jsonMatch[1]); - - // Extract assembly code - const codeMatch = body.match(/```assembly\s*([\s\S]*?)```/); - if (!codeMatch) { console.error("Assembly code block not found"); process.exit(2); } - const code = codeMatch[1]; - - // Validate slug - const ok = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(metadata.slug || ""); - if (!ok) { console.error("Invalid slug"); process.exit(3); } - - // Combine metadata and code into payload (temporary file, not committed) - const payload = { ...metadata, code }; - fs.writeFileSync("payload.json", JSON.stringify(payload, null, 2)); - ' - env: - ISSUE_BODY: ${{ github.event.issue.body }} + - name: Checkout repository + uses: actions/checkout@v6 - - name: Render files from payload + # Works for both triggers: the issue payload is absent on a manual + # dispatch, and the body must reach the renderer as a file rather than an + # expression so issue text can never be interpolated into a shell command. + - name: Resolve the submission issue + id: issue + uses: actions/github-script@v8 + with: + script: | + const fs = require("node:fs"); + const raw = context.payload.issue?.number ?? context.payload.inputs?.issue; + const number = Number(raw); + if (!Number.isInteger(number) || number <= 0) { + core.setFailed(`Not a valid issue number: ${raw}`); + return; + } + const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number: number }); + fs.writeFileSync(`${process.env.RUNNER_TEMP}/issue-body.md`, issue.body ?? ""); + core.setOutput("number", String(number)); + core.setOutput("login", issue.user.login); + core.setOutput("user_id", String(issue.user.id)); + + # Runs before the dependency install so an unusable submission fails in + # seconds and the contributor gets the reason instead of a red run. + - name: Render example files from the issue id: render - run: | - set -euo pipefail - jq -r . payload.json > /dev/null - - SLUG="$(jq -r .slug payload.json)" - TITLE="$(jq -r .title payload.json)" - DESC="$(jq -r .description payload.json)" - AUTHOR="$(jq -r .author payload.json)" - LICENSE="$(jq -r '.license // "CC-BY-4.0"' payload.json)" - SRC_URL="$(jq -r '.sourceUrl // empty' payload.json)" - GH_USER="$(jq -r '.githubUsername // empty' payload.json)" - CODE="$(jq -r .code payload.json)" - DISP="$(jq -r .displayMemory payload.json)" - - BASE_DIR="packages/examples/${SLUG}" - CODE_PATH="${BASE_DIR}/${SLUG}.asm" - META_PATH="${BASE_DIR}/${SLUG}.meta.ts" - mkdir -p "$BASE_DIR" - - # Write code directly to file - printf "%s" "$CODE" > "$CODE_PATH" - - # Helper function for JSON escaping - esc() { printf "%s" "$1" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read())[1:-1])'; } - - # Build the TypeScript file content - { - echo 'import { _ } from "@learn6502/core";' - echo 'import type { ExampleMetaJson } from "../example-meta.ts";' - echo 'export default {' - echo " slug: \"$(esc "$SLUG")\"," - echo " title: _(\"$(esc "$TITLE")\")," - echo " description: _(\"$(esc "$DESC")\")," - echo " author: \"$(esc "$AUTHOR")\"," - echo " license: \"$(esc "$LICENSE")\"," - echo " displayMemory: \"$(esc "$DISP")\"," - if [ -n "$SRC_URL" ]; then - echo " sourceUrl: \"$(esc "$SRC_URL")\"," - fi - if [ -n "$GH_USER" ]; then - echo " githubUsername: \"$(esc "$GH_USER")\"," - fi - echo '} as ExampleMetaJson;' - } > "$META_PATH" - - echo "CODE_PATH=$CODE_PATH" >> $GITHUB_OUTPUT - echo "META_PATH=$META_PATH" >> $GITHUB_OUTPUT - echo "BASE_DIR=$BASE_DIR" >> $GITHUB_OUTPUT - echo "SLUG=$SLUG" >> $GITHUB_OUTPUT - - # Remove temporary file to prevent accidental commit - rm -f payload.json + env: + EXAMPLE_ERROR_FILE: ${{ runner.temp }}/example-error.txt + run: node .github/scripts/example-from-issue.mjs "${RUNNER_TEMP}/issue-body.md" . - name: Create branch + env: + ISSUE: ${{ steps.issue.outputs.number }} run: | - BR="examples/issue-${{ github.event.issue.number }}" - echo "BRANCH=$BR" >> $GITHUB_ENV + BR="examples/issue-${ISSUE}" + echo "BRANCH=$BR" >> "$GITHUB_ENV" git switch -c "$BR" - name: Setup Node.js @@ -111,67 +84,170 @@ jobs: with: node-version: "24" - - name: Enable Corepack - run: corepack enable + - name: Cache gjsify tarball store + uses: actions/cache@v4 + with: + path: ~/.cache/gjsify/tarballs + key: gjsify-tarballs-${{ hashFiles('gjsify-lock.json') }} + restore-keys: gjsify-tarballs- + + # Bootstrap only — `gjsify install` has to be runnable before + # node_modules exists. The build steps below use the workspace-local CLI. + - name: Install gjsify CLI + run: npm install -g @gjsify/cli@^0.8.0 - name: Install dependencies - run: yarn install --immutable + run: gjsify install --immutable + # From here on `gjsify` resolves to the version `gjsify-lock.json` pins, + # not the floating bootstrap copy — the packages declare what they build + # with, and a PR must be built by that. + - name: Use the workspace-pinned gjsify CLI + run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" + + # `@learn6502/core` publishes `dist/`, so it has to exist before anything + # importing it (the examples' meta files, the CLI bundle) can be built. + - name: Build core + run: gjsify workspace @learn6502/core build + + # Regenerates the new example's `index.ts` plus the `examples.ts` barrel + # that exports every example — this is what puts it in front of users. - name: Build examples - run: yarn workspace @learn6502/examples run build + run: gjsify workspace @learn6502/examples build + + # The build once exited 0 having generated nothing at all, which would + # have shipped an example no app can see. Assert the result instead of + # trusting the exit code. + - name: Verify the example is exported + env: + SLUG: ${{ steps.render.outputs.slug }} + BASE_DIR: ${{ steps.render.outputs.base_dir }} + run: | + test -f "$BASE_DIR/index.ts" \ + || { echo "::error::$BASE_DIR/index.ts was not generated — the examples build did nothing."; exit 1; } + grep -qF "\"./$SLUG\"" packages/examples/examples.ts \ + || { echo "::error::$SLUG is missing from packages/examples/examples.ts."; exit 1; } + + # Type-checks the generated `index.ts` against a consumer — a slug can + # produce code that parses nowhere (a dash left in an identifier), and + # that must fail here rather than in the pull request's CI. `learn` builds + # after `examples` because it depends on it, and `app-web`'s types resolve + # to the built output of both. + - name: Type check the generated example + run: | + gjsify workspace @learn6502/learn build + gjsify workspace @learn6502/app-web check + + # The gate the old workflow never had: a submission that does not + # assemble must not reach a pull request. Uses the headless CLI frontend, + # i.e. the same assembler the apps run. + - name: Assemble the submitted example + env: + CODE_PATH: ${{ steps.render.outputs.code_path }} + run: | + gjsify workspace @learn6502/cli build + node packages/cli/dist/cli.js assemble "$CODE_PATH" - name: Format code - run: yarn format + run: gjsify format --write + # Explicit paths, never `git add -A`: the install and build steps leave + # generated files around, and only the example belongs in the commit. - name: Commit with author = issue opener env: - AUTHOR_NAME: ${{ github.event.issue.user.login }} - AUTHOR_EMAIL: ${{ github.event.issue.user.id }}+${{ github.event.issue.user.login }}@users.noreply.github.com + SLUG: ${{ steps.render.outputs.slug }} + BASE_DIR: ${{ steps.render.outputs.base_dir }} + ISSUE: ${{ steps.issue.outputs.number }} + AUTHOR_NAME: ${{ steps.issue.outputs.login }} + AUTHOR_EMAIL: ${{ steps.issue.outputs.user_id }}+${{ steps.issue.outputs.login }}@users.noreply.github.com run: | - git add -A + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git add "$BASE_DIR" packages/examples/examples.ts git -c user.name="github-actions[bot]" \ -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ commit --author="${AUTHOR_NAME} <${AUTHOR_EMAIL}>" \ - -m "feat(example): add ${{ steps.render.outputs.SLUG }} (from issue #${{ github.event.issue.number }}) - - Co-authored-by: ${{ github.event.issue.user.login }} <${{ github.event.issue.user.id }}+${{ github.event.issue.user.login }}@users.noreply.github.com> - Signed-off-by: ${{ github.event.issue.user.login }} <${{ github.event.issue.user.id }}+${{ github.event.issue.user.login }}@users.noreply.github.com> - " + -m "Examples: add ${SLUG} (from issue #${ISSUE})" \ + -m "Co-authored-by: ${AUTHOR_NAME} <${AUTHOR_EMAIL}> + Signed-off-by: ${AUTHOR_NAME} <${AUTHOR_EMAIL}>" + # The branch belongs to this workflow alone and a re-dispatch regenerates + # it from the issue, so overwriting it is the intended behaviour — review + # fixes belong on the pull request, not on a branch that gets rebuilt. - name: Push - run: git push -u origin "$BRANCH" + run: git push --force origin "HEAD:refs/heads/${BRANCH}" - - name: Open PR + # Every value reaches the script through `env`, never through an + # expression spliced into the JS source — issue text must not be able to + # become code. + - name: Open or update the pull request id: open_pr uses: actions/github-script@v8 + env: + SLUG: ${{ steps.render.outputs.slug }} + ISSUE: ${{ steps.issue.outputs.number }} + META_PATH: ${{ steps.render.outputs.meta_path }} + CODE_PATH: ${{ steps.render.outputs.code_path }} with: script: | - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Add example: ${{ steps.render.outputs.SLUG }} (from issue #${{ github.event.issue.number }})', - head: '${{ env.BRANCH }}', - base: 'main', - body: `Generated from issue #${{ github.event.issue.number }} by workflow. - - Files: - - \`${{ steps.render.outputs.META_PATH }}\` - - \`${{ steps.render.outputs.CODE_PATH }}\` - - Closes #${{ github.event.issue.number }}` + const { BRANCH, SLUG, ISSUE, META_PATH, CODE_PATH } = process.env; + const title = `Examples: add ${SLUG} (from issue #${ISSUE})`; + const body = [ + `Generated from issue #${ISSUE} by the \`issue-to-pr\` workflow.`, + "", + "Files:", + `- \`${META_PATH}\``, + `- \`${CODE_PATH}\``, + "", + "The submitted source assembles with `learn6502 assemble`.", + "", + `Closes #${ISSUE}`, + ].join("\n"); + + // A retried dispatch pushes to the same branch, so reuse the pull + // request already open for it instead of failing on a duplicate. + const { data: existing } = await github.rest.pulls.list({ + ...context.repo, + head: `${context.repo.owner}:${BRANCH}`, + state: "open", }); - console.log(`Pull request created: ${pr.html_url}`); - core.setOutput('pr_number', pr.number); - core.setOutput('pr_url', pr.html_url); + const pr = existing.length + ? (await github.rest.pulls.update({ ...context.repo, pull_number: existing[0].number, title, body })).data + : (await github.rest.pulls.create({ ...context.repo, title, head: BRANCH, base: "main", body })).data; + + core.setOutput("pr_number", String(pr.number)); + core.setOutput("pr_url", pr.html_url); + core.notice(`Pull request ready: ${pr.html_url}`); - name: Comment on issue - id: comment uses: actions/github-script@v8 + env: + ISSUE: ${{ steps.issue.outputs.number }} + PR_NUMBER: ${{ steps.open_pr.outputs.pr_number }} + with: + script: | + await github.rest.issues.createComment({ + ...context.repo, + issue_number: Number(process.env.ISSUE), + body: `✅ Pull request created: #${process.env.PR_NUMBER}\n\nYour example assembles and has been added to the examples package. It will be reviewed and merged soon. Thank you for contributing! 🎉`, + }); + + # Without this a rejected submission is silent: the issue just sits there + # and the contributor never learns what to fix. + - name: Report the failure on the issue + if: failure() && steps.issue.outputs.number != '' + uses: actions/github-script@v8 + env: + EXAMPLE_ERROR_FILE: ${{ runner.temp }}/example-error.txt + ISSUE: ${{ steps.issue.outputs.number }} with: script: | + const fs = require("node:fs"); + const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; + const reason = fs.existsSync(process.env.EXAMPLE_ERROR_FILE) + ? fs.readFileSync(process.env.EXAMPLE_ERROR_FILE, "utf8").trim() + : `The example could not be built. See the [workflow run](${runUrl}) for details.`; await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: '✅ Pull request created: #${{ steps.open_pr.outputs.pr_number }}\n\nYour example will be reviewed and merged soon. Thank you for contributing! 🎉' - }) + ...context.repo, + issue_number: Number(process.env.ISSUE), + body: `❌ This submission could not be turned into a pull request.\n\n${reason}\n\nEdit the issue and re-run the [workflow](${runUrl}) once it is fixed — no need to open a new issue.`, + });