Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions .github/workflows/reusable-solidity-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,13 @@ on:
required: false
default: false
commentPR:
description: "True if you want to add a comment with the path to the
generated files in the PR invoking the workflow. If the workflow is
not triggered by the `pull_request` event, having this input set to
`true` will not brake the execution."
description: "True if you want to create or update a preview comment
for this projectDir in the PR invoking the workflow. Requires
exportAsGHArtifacts=true and a token with pull-requests: write
permission. If the workflow is not triggered by the `pull_request`
event, no comment is posted. Runs for the same projectDir are not
serialized: overlapping runs can both create a comment, and
subsequent runs update the first match."
type: boolean
required: false
default: false
Expand Down Expand Up @@ -250,14 +253,39 @@ jobs:
&& inputs.commentPR == true
&& startsWith(github.ref, 'refs/pull')
uses: actions/github-script@v8
env:
SOLIDITY_DOCS_PROJECT_DIR: ${{ inputs.projectDir }}
with:
script: |
github.rest.issues.createComment({
const project = encodeURIComponent(process.env.SOLIDITY_DOCS_PROJECT_DIR || '.')
const marker = `<!-- solidity-docs-preview:${project} -->`
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const body = `${marker}\nSolidity API documentation preview available in the artifacts of the ${runUrl} check.`
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Solidity API documentation preview available in the artifacts of the https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id}} check.'
per_page: 100
})
// The default GITHUB_TOKEN authors comments as github-actions[bot].
// Only our marker at the start of a workflow-owned comment is eligible.
const existing = comments.find(comment =>
comment.user?.login === 'github-actions[bot]' &&
comment.user?.type === 'Bot' &&
comment.body?.split('\n', 1)[0].trim() === marker
)
if (existing) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body
})
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body
})
}

- name: Import GPG key
if: inputs.publish == true && inputs.verifyCommits == true
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ reviewed full commit SHA. Each action directory documents its inputs.
[MIGRATION.md](MIGRATION.md) describes coordinated consumer changes, credentials,
and rollout order. Do not use the inherited v2 tags for the maintained actions.

## Solidity documentation previews

The [reusable Solidity docs workflow](.github/workflows/reusable-solidity-docs.yml)
can post an artifact preview link when both `exportAsGHArtifacts` and `commentPR`
are enabled for a pull request. Commenting defaults to `false`; callers that
enable it must grant `pull-requests: write` to the workflow's `GITHUB_TOKEN`.
Sequential runs update the comment identified by `projectDir`, so separate
projects in the same PR keep separate comments; overlapping runs of the same
`projectDir` are not serialized and may leave duplicate preview comments. Only
comments authored by `github-actions[bot]` with the project's preview marker
are updated. Older unmarked comments and comments written by other users are
left untouched.

## Development

Use Node 24 and run:
Expand Down
250 changes: 250 additions & 0 deletions test/solidity-docs-comment.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { rejects } from "assert"
import { readFileSync } from "fs"
import { runInNewContext } from "vm"
import { expect } from "chai"
import { load } from "js-yaml"

const workflow = load(
readFileSync(
new URL("../.github/workflows/reusable-solidity-docs.yml", import.meta.url),
"utf8"
)
)
const commentStep = workflow.jobs["docs-generate-html-and-publish"].steps.find(
(step) => step.uses?.startsWith("actions/github-script@")
)
const repo = { owner: "example", repo: "contracts" }
const bot = { login: "github-actions[bot]", type: "Bot" }
const rootMarker = "<!-- solidity-docs-preview:. -->"

// Execute the shipped inline script: reusable workflows check out the caller's
// repository, so they cannot depend on a local helper from this repository.
function postPreview(github, { projectDir = "", runId = 200 } = {}) {
return runInNewContext(`(async () => {\n${commentStep.with.script}\n})()`, {
github,
context: {
repo,
issue: { number: 42 },
runId,
serverUrl: "https://github.com",
},
process: { env: { SOLIDITY_DOCS_PROJECT_DIR: projectDir } },
})
}

function mockGitHub(initialComments = []) {
const comments = initialComments.map((comment) => ({ ...comment }))
const calls = { list: [], create: [], update: [] }
const issues = {
async listComments(params) {
calls.list.push(params)
const { page = 1, per_page: perPage = 30 } = params
return { data: comments.slice((page - 1) * perPage, page * perPage) }
},
async createComment(params) {
calls.create.push(params)
const comment = {
id: comments.length + 1000,
user: bot,
body: params.body,
}
comments.push(comment)
return { data: comment }
},
async updateComment(params) {
calls.update.push(params)
const comment = comments.find(({ id }) => id === params.comment_id)
comment.body = params.body
return { data: comment }
},
}
const github = {
rest: { issues },
async paginate(method, params) {
expect(method).to.equal(issues.listComments)
const result = []
for (let page = 1; ; page += 1) {
const { data } = await method({ ...params, page })
result.push(...data)
if (data.length < (params.per_page || 30)) return result
}
},
}
return { github, comments, calls }
}

describe("Solidity docs preview comments", () => {
it("keeps commenting opt-in and passes the project as data", () => {
expect(workflow.on.workflow_call.inputs.commentPR.default).to.be.false
expect(commentStep.if.replace(/\s+/g, " ").trim()).to.equal(
"inputs.exportAsGHArtifacts == true && inputs.commentPR == true && startsWith(github.ref, 'refs/pull')"
)
expect(commentStep.env.SOLIDITY_DOCS_PROJECT_DIR).to.equal(
"${{ inputs.projectDir }}"
)
expect(commentStep.with.script).not.to.include("${{")
})

it("creates the first preview comment with a stable project marker", async () => {
const { github, calls } = mockGitHub()

await postPreview(github)

expect(calls.create).to.deep.equal([
{
...repo,
issue_number: 42,
body: `${rootMarker}\nSolidity API documentation preview available in the artifacts of the https://github.com/example/contracts/actions/runs/200 check.`,
},
])
expect(calls.update).to.be.empty
})

it("updates the workflow-owned comment to point to the latest run", async () => {
const { github, comments, calls } = mockGitHub()
await postPreview(github, { runId: 100 })
const originalId = comments[0].id

await postPreview(github, { runId: 201 })

expect(calls.create).to.have.lengthOf(1)
expect(calls.update).to.deep.equal([
{
...repo,
comment_id: originalId,
body: `${rootMarker}\nSolidity API documentation preview available in the artifacts of the https://github.com/example/contracts/actions/runs/201 check.`,
},
])
expect(comments).to.have.lengthOf(1)
expect(comments[0].body).to.include(rootMarker)
expect(comments[0].body).to.include("/actions/runs/201")
expect(comments[0].body).not.to.include("/actions/runs/100")
})

it("matches a workflow-owned comment whose body uses CRLF line endings", async () => {
const { github, comments, calls } = mockGitHub([
{
id: 1,
user: bot,
body: `${rootMarker}\r\nPreview edited in the web UI`,
},
])

await postPreview(github, { runId: 301 })

expect(calls.create).to.be.empty
expect(calls.update).to.have.lengthOf(1)
expect(calls.update[0].comment_id).to.equal(1)
expect(comments[0].body).to.include("/actions/runs/301")
})

it("maintains separate comments for the root and multiple subprojects", async () => {
const { github, comments, calls } = mockGitHub()
await postPreview(github, { projectDir: "", runId: 100 })
await postPreview(github, { projectDir: "/v1/solidity", runId: 101 })
await postPreview(github, { projectDir: "/v2/solidity", runId: 102 })
const originalBodies = comments.map(({ body }) => body)

await postPreview(github, { projectDir: "/v1/solidity", runId: 203 })

expect(calls.create).to.have.lengthOf(3)
expect(calls.update).to.have.lengthOf(1)
expect(calls.update[0].comment_id).to.equal(comments[1].id)
expect(comments[0].body).to.equal(originalBodies[0])
expect(comments[2].body).to.equal(originalBodies[2])
expect(comments[1].body).to.include("/actions/runs/203")
expect(
new Set(comments.map(({ body }) => body.split("\n")[0])).size
).to.equal(3)
})

it("does not edit matching comments from other users or bots", async () => {
const initialComments = [
{ id: 1, user: { login: "contributor", type: "User" } },
{ id: 2, user: { login: "another-app[bot]", type: "Bot" } },
{ id: 3, user: null },
].map((comment) => ({ ...comment, body: `${rootMarker}\nCopied preview` }))
const { github, comments, calls } = mockGitHub(initialComments)

await postPreview(github)

expect(calls.update).to.be.empty
expect(calls.create).to.have.lengthOf(1)
expect(comments.slice(0, 3)).to.deep.equal(initialComments)

await postPreview(github, { runId: 201 })

expect(calls.create).to.have.lengthOf(1)
expect(calls.update[0].comment_id).to.equal(comments[3].id)
expect(comments.slice(0, 3)).to.deep.equal(initialComments)
})

it("leaves unmarked, quoted, and empty workflow comments unchanged", async () => {
const initialComments = [
{
id: 1,
user: bot,
body: "Solidity API documentation preview available",
},
{ id: 2, user: bot, body: `Quoted comment:\n${rootMarker}\nPreview` },
{ id: 3, user: bot, body: null },
]
const { github, comments, calls } = mockGitHub(initialComments)

await postPreview(github)

expect(calls.update).to.be.empty
expect(calls.create).to.have.lengthOf(1)
expect(comments.slice(0, 3)).to.deep.equal(initialComments)
})

it("finds an existing preview after the first page of comments", async () => {
const initialComments = Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
user: bot,
body: "Unrelated workflow comment",
}))
initialComments.push({
id: 101,
user: bot,
body: `${rootMarker}\nOld preview`,
})
const { github, calls } = mockGitHub(initialComments)

await postPreview(github)

expect(calls.list).to.deep.equal([
{ ...repo, issue_number: 42, per_page: 100, page: 1 },
{ ...repo, issue_number: 42, per_page: 100, page: 2 },
])
expect(calls.create).to.be.empty
expect(calls.update[0].comment_id).to.equal(101)
})

it("safely encodes project names containing quotes and comment delimiters", async () => {
const { github, comments, calls } = mockGitHub()
const projectDir = "/contracts/\"' -->\n${{ example }}"

await postPreview(github, { projectDir })
await postPreview(github, { projectDir, runId: 201 })

expect(calls.create).to.have.lengthOf(1)
expect(calls.update).to.have.lengthOf(1)
const marker = comments[0].body.split("\n")[0]
expect(marker).to.include("%2Fcontracts%2F")
expect(marker).to.include("%3E%0A")
expect(marker.match(/-->/g)).to.have.lengthOf(1)
})

it("propagates listing failures without creating a duplicate", async () => {
const { github, calls } = mockGitHub()
github.rest.issues.listComments = async () => {
throw new Error("listing failed")
}

await rejects(() => postPreview(github), /listing failed/)

expect(calls.create).to.be.empty
expect(calls.update).to.be.empty
})
})
Loading