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
2 changes: 1 addition & 1 deletion .github/actions/setup-repo/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ runs:
using: "composite"
steps:
- name: Use Node.js ${{ inputs.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: "yarn"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"search-eval:corpus": "firebase --project demo-dtp emulators:exec --only firestore --import tests/integration/exportedTestData \"yarn firebase-admin run-script exportSearchCorpus --env local bills\"",
"firebase-admin": "ts-node --swc -P tsconfig.script.json scripts/firebase-admin",
"generate-stories": "ts-node -P tsconfig.script.json scripts/generate-stories",
"create-prod-release-pr": "node scripts/create-prod-release-pr.js",
"compose": "docker compose -f infra/docker-compose.yml",
"dev:functions": "yarn --cwd functions dev",
"export-test-data": "firebase --project demo-dtp emulators:export --only firestore,storage tests/integration/exportedTestData",
Expand Down
10 changes: 10 additions & 0 deletions pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ _Add some screenshots highlighting your changes._

_If you've run against limitations or caveats, include them here. Include follow-up issues as well._

# Extra Deploy Steps

_List any steps beyond a standard merge and Vercel/Firebase deploy that are needed to deploy this PR, such as running backfill scripts, configuring third-party APIs, setting environment variables or secrets, configuring monitoring or alerts, or flipping flags. Use a bulleted list; for each step, specify the action (including exact CLI commands when needed) and whether it must happen **before** or **after** the standard Vercel/Firebase deploy._

_Example only:_

- **Before deploy:** Set the `FEATURE_API_KEY` secret using `vercel env add FEATURE_API_KEY`.
- **After deploy:** Run `yarn backfill:new-field --env dev` to populate the new field for existing records.
- **After deploy:** Enable the `newFeature` flag in the feature flag settings.

# Steps to test/reproduce

_For each feature or bug fix, create a step by step list for how a reviewer can test it out. E.g.:_
Expand Down
16 changes: 16 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,22 @@ yarn setRole --env <local|dev|prod> [args]

---

## `create-prod-release-pr`

Drafts the "Deploy to PROD" pull request from `main` into `prod` on `codeforboston/maple`. The PR body has two sections:

- **Changelog:** one bullet per PR merged to `main` since the last release to `prod`.
- **Extra Deploy Steps:** the items from each of those PRs' "Extra Deploy Steps" section, each tagged with its PR number. Template placeholder text and "None" entries are skipped.

```sh
yarn create-prod-release-pr # dry run: prints the title and body
yarn create-prod-release-pr --create # opens the PR
```

Requires the GitHub CLI (`gh`), authenticated with permission to open PRs on the repo. The script only reads PRs and commits and, with `--create`, opens one PR; it refuses if a `main` → `prod` PR is already open. It never approves or merges.

---

## CI test

The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyScope` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes.
178 changes: 178 additions & 0 deletions scripts/create-prod-release-pr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/*
* Drafts (and optionally opens) the "Deploy to PROD" pull request from `main`
* into `prod` on codeforboston/maple.
*
* Usage:
* yarn create-prod-release-pr # dry run: prints the title and body
* yarn create-prod-release-pr --create # opens the PR
*
* Requires the GitHub CLI (`gh`), authenticated. The script only reads PRs and
* commits and, with --create, opens one PR. It never approves or merges.
*/
const { execFileSync } = require("child_process")

const REPO = "codeforboston/maple"
const BASE = "prod"
const HEAD = "main"

// Lines from pull_request_template.md that authors may leave in unedited.
const TEMPLATE_EXAMPLE_STEPS = [
"**Before deploy:** Set the `FEATURE_API_KEY` secret using `vercel env add FEATURE_API_KEY`.",
"**After deploy:** Run `yarn backfill:new-field --env dev` to populate the new field for existing records.",
"**After deploy:** Enable the `newFeature` flag in the feature flag settings."
]

const gh = (...args) =>
execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })

const ghJson = (...args) => JSON.parse(gh("api", ...args))

const unreleasedShas = () =>
gh(
"api",
"--paginate",
`repos/${REPO}/compare/${BASE}...${HEAD}`,
"--jq",
".commits[].sha"
)
.split("\n")
.filter(Boolean)

const commitInfo = sha => {
const commit = ghJson(`repos/${REPO}/commits/${sha}`)
const pulls = ghJson(`repos/${REPO}/commits/${sha}/pulls`).filter(
p => p.base.ref === HEAD && p.merged_at
)
return { sha, message: commit.commit.message.split("\n")[0], pulls }
}

/** Returns the bullet items under an "Extra Deploy Steps" heading. */
const extractDeploySteps = body => {
const lines = (body ?? "")
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/\r/g, "")
.split("\n")
const start = lines.findIndex(l =>
/^#{1,6}\s*extra deploy steps\s*$/i.test(l.trim())
)
if (start === -1) return []

const section = []
for (const line of lines.slice(start + 1)) {
if (/^#{1,6}\s/.test(line)) break
section.push(line)
}

const items = []
let sawBullet = false
for (const line of section) {
const bullet = line.match(
/^\s{0,3}(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s*)?(.*)$/
)
if (bullet) {
sawBullet = true
items.push(bullet[1].trim())
} else if (sawBullet && /^\s+\S/.test(line) && items.length) {
items[items.length - 1] += " " + line.trim()
}
}

// Fall back to plain paragraphs when the author didn't use bullets.
const candidates = sawBullet
? items
: section.map(l => l.trim()).filter(Boolean)

return candidates.filter(
item =>
item &&
!item.startsWith("_") &&
!/^(none|n\/a|na|nothing|no)\.?$/i.test(item) &&
!TEMPLATE_EXAMPLE_STEPS.includes(item)
)
}

const buildBody = entries => {
const prs = new Map()
const loose = []
for (const { sha, message, pulls } of entries) {
if (pulls.length === 0) loose.push({ sha, message })
for (const pr of pulls) prs.set(pr.number, pr)
}
const sorted = [...prs.values()].sort((a, b) => a.number - b.number)

const changelog = [
...sorted.map(pr => `* #${pr.number} ${pr.title}`),
...loose.map(c => `* ${c.sha.slice(0, 7)} ${c.message} (no PR)`)
]

const steps = sorted.flatMap(pr =>
extractDeploySteps(pr.body).map(s => `- [ ] ${s} (#${pr.number})`)
)

return [
"## Changelog",
"",
...(changelog.length ? changelog : ["_No unreleased changes._"]),
"",
"## Extra Deploy Steps",
"",
...(steps.length ? steps : ["_None._"]),
""
].join("\n")
}

const main = () => {
const create = process.argv.includes("--create")
const shas = unreleasedShas()
if (shas.length === 0) {
console.log(`${BASE} is up to date with ${HEAD}. Nothing to release.`)
return
}

const now = new Date()
const title = `Deploy to PROD ${now.getMonth() + 1}/${now.getDate()}/${String(
now.getFullYear()
).slice(2)}`
const body = buildBody(shas.map(commitInfo))

console.log(`${title}\n\n${body}`)
if (!create) {
console.log("(dry run: pass --create to open the PR)")
return
}

const existing = gh(
"pr",
"list",
"--repo",
REPO,
"--base",
BASE,
"--head",
HEAD,
"--state",
"open",
"--json",
"url",
"--jq",
".[].url"
).trim()
if (existing) {
console.error(`An open ${HEAD} -> ${BASE} PR already exists: ${existing}`)
process.exit(1)
}

// `gh pr create --body` can silently drop the body on this repo; use the API.
const payload = JSON.stringify({ title, head: HEAD, base: BASE, body })
const pr = JSON.parse(
execFileSync(
"gh",
["api", `repos/${REPO}/pulls`, "--method", "POST", "--input", "-"],
{ input: payload, encoding: "utf8" }
)
)
console.log(`Opened ${pr.html_url}`)
}

main()
Loading
Loading