setup-git-repo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+
4
+ /**
5
+ * Every optional badge in the template README carries a `<!-- badge:NAME -->`
6
+ * marker at the start of its line. A badge whose value the user did not supply
7
+ * would render as a broken image pointing at a literal `{{DOI}}`, so its whole
8
+ * line is dropped; a badge that is kept has only its marker removed.
9
+ *
10
+ * Maps marker name -> the placeholders that badge needs to be renderable.
11
+ */
12
+ export const OPTIONAL_BADGES = {
13
+ doi: ["DOI"],
14
+ docs: ["DOCS_URL"],
15
+ api: ["API_URL"],
16
+ youtube: ["YOUTUBE_URL"],
17
+ npm: ["PACKAGE"],
18
+ uptime: ["UPTIME_ID"],
19
+ discord: ["DISCORD_ID", "DISCORD_INVITE"],
20
+ };
21
+
22
+ const BADGE_MARKER = /<!--\s*badge:([a-z0-9-]+)\s*-->/i;
23
+
24
+ /** Badge names whose placeholders all have a non-empty value. */
25
+ export function configuredBadges(values) {
26
+ return new Set(
27
+ Object.entries(OPTIONAL_BADGES)
28
+ .filter(([, keys]) => keys.every((k) => typeof values[k] === "string" && values[k].trim() !== ""))
29
+ .map(([name]) => name),
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Drop the lines of unconfigured badges and strip markers from the rest.
35
+ *
36
+ * Operates line by line, which is why the template keeps one `<a>` per line.
37
+ */
38
+ export function applyBadges(content, values) {
39
+ const keep = configuredBadges(values);
40
+ const out = [];
41
+
42
+ for (const line of content.split("\n")) {
43
+ const match = line.match(BADGE_MARKER);
44
+ if (!match) {
45
+ out.push(line);
46
+ continue;
47
+ }
48
+ if (!keep.has(match[1].toLowerCase())) continue;
49
+ // Normalize indentation: the marker sits at column 0, but the badge
50
+ // lines around it are indented, and the block is read by humans.
51
+ out.push(` ${line.replace(BADGE_MARKER, "").trimStart()}`);
52
+ }
53
+
54
+ return out.join("\n");
55
+ }
56
+
57
+ /**
58
+ * Replace every `{{KEY}}` with its value.
59
+ *
60
+ * An unknown or empty key is left as-is rather than replaced with an empty
61
+ * string: a visible `{{THING}}` in the output is a bug you notice, whereas a
62
+ * silent blank in a URL is one you ship.
63
+ */
64
+ export function substitute(content, values) {
65
+ return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (whole, key) => {
66
+ const value = values[key];
67
+ return typeof value === "string" && value.trim() !== "" ? value : whole;
68
+ });
69
+ }
70
+
71
+ /** Render one template file: badge pruning first, then placeholders. */
72
+ export function render(content, values) {
73
+ return substitute(applyBadges(content, values), values);
74
+ }
75
+
76
+ /** Recursively list files under `dir`, as paths relative to it, sorted. */
77
+ export function collectFiles(dir) {
78
+ const out = [];
79
+
80
+ const walk = (current) => {
81
+ for (const entry of readdirSync(current).sort()) {
82
+ const full = join(current, entry);
83
+ if (statSync(full).isDirectory()) {
84
+ // Nothing in the template needs these, and copying a stale build into a
85
+ // fresh repo is worse than useless.
86
+ if (entry === "node_modules" || entry === ".turbo" || entry === "dist") continue;
87
+ walk(full);
88
+ continue;
89
+ }
90
+ out.push(relative(dir, full).split(sep).join("/"));
91
+ }
92
+ };
93
+
94
+ walk(dir);
95
+ return out.sort();
96
+ }
97
+
98
+ /**
99
+ * Decide what to do with each template file given what already exists.
100
+ *
101
+ * `only` narrows the run to a subset — the `--workflows-only` / `--badges-only`
102
+ * flags — matched as path prefixes.
103
+ */
104
+ export function planFiles(files, { exists, force = false, only = null }) {
105
+ const plan = { write: [], overwrite: [], skip: [] };
106
+
107
+ for (const file of files) {
108
+ if (only && !only.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))) {
109
+ continue;
110
+ }
111
+ if (!exists(file)) {
112
+ plan.write.push(file);
113
+ } else if (force) {
114
+ plan.overwrite.push(file);
115
+ } else {
116
+ plan.skip.push(file);
117
+ }
118
+ }
119
+
120
+ return plan;
121
+ }
122
+
123
+ /**
124
+ * Where a template file lands in the target repo.
125
+ *
126
+ * `gitignore` becomes `.gitignore`. It is stored without the dot because npm
127
+ * strips `.gitignore` out of published tarballs — a template that carried one
128
+ * would work from a checkout and silently lose the file when installed from the
129
+ * registry.
130
+ */
131
+ export function destinationFor(file) {
132
+ if (file === "gitignore") return ".gitignore";
133
+ return file;
134
+ }
135
+
136
+ /** Path prefixes selected by the `--*-only` flags. */
137
+ export const SUBSETS = {
138
+ workflows: [".github"],
139
+ badges: ["README.md"],
140
+ docs: ["docs"],
141
+ turbo: ["turbo.json", "package.json", "vitest.config.ts"],
142
+ };
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print the next version of a package that the registry has not already spent.
4
+ *
5
+ * Bumping one patch above the `latest` dist-tag is not enough. `latest` lags
6
+ * any version that an interrupted publish staged, and npm answers a PUT for one
7
+ * of those with `E409 ... Cannot publish over previously staged version`. Those
8
+ * numbers show up in the full version list and in the release timeline, so both
9
+ * are consulted here.
10
+ *
11
+ * node .github/scripts/next-free-version.mjs <package-name> <current-version>
12
+ */
13
+ import { execFileSync } from "node:child_process";
14
+
15
+ const [, , name, current] = process.argv;
16
+
17
+ if (!name || !current) {
18
+ console.error("usage: next-free-version.mjs <package-name> <current-version>");
19
+ process.exit(2);
20
+ }
21
+
22
+ /** Run `npm view` and parse JSON, treating any failure as "no data". */
23
+ function npmView(field) {
24
+ try {
25
+ const out = execFileSync("npm", ["view", name, field, "--json"], {
26
+ encoding: "utf8",
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ });
29
+ return JSON.parse(out);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ const taken = new Set();
36
+
37
+ const versions = npmView("versions");
38
+ if (Array.isArray(versions)) for (const v of versions) taken.add(v);
39
+ else if (typeof versions === "string") taken.add(versions);
40
+
41
+ // `time` carries every version the registry has a timestamp for, including ones
42
+ // that were unpublished or staged and never completed.
43
+ const time = npmView("time");
44
+ if (time && typeof time === "object") {
45
+ for (const key of Object.keys(time)) {
46
+ if (key !== "created" && key !== "modified") taken.add(key);
47
+ }
48
+ }
49
+
50
+ const parse = (v) => {
51
+ const [core] = String(v).split(/[-+]/);
52
+ const parts = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
53
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
54
+ };
55
+
56
+ // Start from the highest number the registry knows about, not from `current`:
57
+ // a local version behind the registry would otherwise propose versions that are
58
+ // all taken, one refused publish at a time.
59
+ let [major, minor, patch] = parse(current);
60
+ for (const v of taken) {
61
+ const [ma, mi, pa] = parse(v);
62
+ if (ma > major || (ma === major && (mi > minor || (mi === minor && pa > patch)))) {
63
+ [major, minor, patch] = [ma, mi, pa];
64
+ }
65
+ }
66
+
67
+ let next;
68
+ do {
69
+ patch += 1;
70
+ next = `${major}.${minor}.${patch}`;
71
+ } while (taken.has(next));
72
+
73
+ console.log(next);
@@ -0,0 +1,96 @@
1
+ # A twice-daily sweep that keeps branches from going stale:
2
+ # 1. merges open PRs that are already clean, approved (or unreviewed) and green
3
+ # 2. opens a PR for any pushed branch that never got one
4
+ #
5
+ # Both halves are idempotent, so a delayed or duplicated run is harmless.
6
+ name: Auto-merge PRs and create missing PRs
7
+
8
+ on:
9
+ schedule:
10
+ # 00:00 and 12:00 UTC. Scheduled workflows can be delayed for hours during
11
+ # periods of high GitHub Actions load — never rely on the exact minute.
12
+ - cron: "0 */12 * * *"
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: write
17
+ pull-requests: write
18
+
19
+ concurrency:
20
+ group: auto-merge-and-create-prs
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ maintain-pull-requests:
25
+ runs-on: ubuntu-latest
26
+ env:
27
+ GH_TOKEN: ${{ secrets.GIT_TOKEN }}
28
+ GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
29
+ BASE_BRANCH: ${{ github.event.repository.default_branch }}
30
+ steps:
31
+ - name: Merge eligible existing pull requests
32
+ env:
33
+ REPOSITORY: ${{ github.repository }}
34
+ run: |
35
+ set -euo pipefail
36
+
37
+ # Eligible means: not a draft, mergeable with no conflicts (CLEAN),
38
+ # either approved or not reviewed at all, and every check that
39
+ # reported came back SUCCESS/SKIPPED/NEUTRAL.
40
+ gh pr list \
41
+ --repo "$REPOSITORY" \
42
+ --state open \
43
+ --json number,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup \
44
+ --jq '.[] | select(.isDraft == false) | select(.mergeStateStatus == "CLEAN") | select((.reviewDecision == "APPROVED") or (.reviewDecision == "")) | select(all(.statusCheckRollup[]?; (.conclusion == "SUCCESS" or .conclusion == "SKIPPED" or .conclusion == "NEUTRAL"))) | .number' \
45
+ | while read -r pr_number; do
46
+ echo "Attempting to merge PR #${pr_number}"
47
+ gh pr merge "$pr_number" \
48
+ --repo "$REPOSITORY" \
49
+ --merge \
50
+ --delete-branch \
51
+ --auto || echo "Could not merge PR #${pr_number}; it may require review, checks, or conflict resolution."
52
+ done
53
+
54
+ - name: Create pull requests for branches without one
55
+ env:
56
+ REPOSITORY: ${{ github.repository }}
57
+ run: |
58
+ set -euo pipefail
59
+
60
+ # No actions/checkout above: this step only needs refs, so it fetches
61
+ # them into an empty workspace instead of paying for a full checkout.
62
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
63
+ git init -q "$GITHUB_WORKSPACE"
64
+ cd "$GITHUB_WORKSPACE"
65
+ git remote add origin "https://x-access-token:${GIT_TOKEN}@github.com/${REPOSITORY}.git"
66
+ git fetch --quiet origin '+refs/heads/*:refs/remotes/origin/*'
67
+
68
+ base_sha="$(git rev-parse "origin/${BASE_BRANCH}")"
69
+
70
+ git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin \
71
+ | grep -Ev "^(HEAD|${BASE_BRANCH})$" \
72
+ | while read -r branch; do
73
+ open_pr="$(gh pr list --repo "$REPOSITORY" --state open --head "$branch" --json number --jq 'length')"
74
+ merged_pr="$(gh pr list --repo "$REPOSITORY" --state merged --head "$branch" --json number --jq 'length')"
75
+ branch_sha="$(git rev-parse "origin/${branch}")"
76
+
77
+ # A merged PR counts: reopening one for a branch whose work
78
+ # already landed would create an empty, permanently open PR.
79
+ if [ "$open_pr" -gt 0 ] || [ "$merged_pr" -gt 0 ]; then
80
+ echo "Skipping ${branch}: a pull request already exists or was previously merged."
81
+ continue
82
+ fi
83
+
84
+ if git merge-base --is-ancestor "$branch_sha" "$base_sha"; then
85
+ echo "Skipping ${branch}: it is already contained in ${BASE_BRANCH}."
86
+ continue
87
+ fi
88
+
89
+ echo "Creating PR for ${branch} -> ${BASE_BRANCH}"
90
+ gh pr create \
91
+ --repo "$REPOSITORY" \
92
+ --head "$branch" \
93
+ --base "$BASE_BRANCH" \
94
+ --title "Merge ${branch} into ${BASE_BRANCH}" \
95
+ --body "Automated pull request for branch \`${branch}\`."
96
+ done
@@ -0,0 +1,50 @@
1
+ # Enables auto-merge on pull requests opened by trusted agents and maintainers,
2
+ # so an agent-authored change lands on its own once required checks pass.
3
+ #
4
+ # `--auto` is the safe path: GitHub holds the merge until branch protection is
5
+ # satisfied. It only works when "Allow auto-merge" is on in repo settings AND
6
+ # the base branch has protection with at least one required status check —
7
+ # without both, GitHub rejects the flag and the fallback below merges directly.
8
+ # On a repo with no branch protection, that fallback merges without waiting for
9
+ # CI, which is why the actor allowlist matters.
10
+ name: Auto-merge agent PRs
11
+
12
+ on:
13
+ pull_request:
14
+ types: [opened, synchronize, reopened]
15
+
16
+ permissions:
17
+ contents: write
18
+ pull-requests: write
19
+
20
+ jobs:
21
+ auto-merge:
22
+ runs-on: ubuntu-latest
23
+ # Extend this list with the bots and maintainers whose PRs may self-merge.
24
+ if: >
25
+ github.actor == 'claude[bot]' ||
26
+ github.actor == 'anthropic-claude[bot]' ||
27
+ github.actor == '{{OWNER}}'
28
+ steps:
29
+ - name: Enable auto-merge (or merge directly)
30
+ env:
31
+ # A PAT rather than GITHUB_TOKEN: merges made with GITHUB_TOKEN do not
32
+ # trigger further workflows, so deploy/publish runs on the default
33
+ # branch would never fire.
34
+ GH_TOKEN: ${{ secrets.GIT_TOKEN }}
35
+ PR: ${{ github.event.pull_request.number }}
36
+ REPO: ${{ github.repository }}
37
+ HEAD_REF: ${{ github.head_ref }}
38
+ run: |
39
+ # Long-lived branches must survive their own merges.
40
+ case "$HEAD_REF" in
41
+ production|prod|staging|develop) MERGE_OPTS="--squash" ;;
42
+ *) MERGE_OPTS="--squash --delete-branch" ;;
43
+ esac
44
+
45
+ if gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS --auto; then
46
+ echo "Auto-merge enabled for PR #$PR"
47
+ else
48
+ echo "Auto-merge unavailable, attempting direct merge"
49
+ gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS
50
+ fi
@@ -0,0 +1,85 @@
1
+ # Publishes a browsable HTML test report to Cloudflare Workers on every push to
2
+ # the default branch, so the README's "Test Report" badge links somewhere real.
3
+ name: Test Reports
4
+
5
+ on:
6
+ push:
7
+ branches: [{{DEFAULT_BRANCH}}]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ deploy:
12
+ name: Generate & Deploy Test Reports
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: oven-sh/setup-bun@v2
18
+
19
+ - name: Install dependencies
20
+ run: bun install --ignore-scripts
21
+
22
+ - name: Build workspace libraries consumed via dist
23
+ run: bunx turbo run build
24
+
25
+ - name: Run tests with HTML reporter
26
+ run: bun run test:report
27
+ # A red suite must still publish its report — that report is how you
28
+ # find out what went red.
29
+ continue-on-error: true
30
+
31
+ # What the step above must not do is leave `dist` missing: `wrangler
32
+ # deploy` treats an absent `assets.directory` as a hard error, so the run
33
+ # would end on a config error instead of on the test signal. Publish a
34
+ # placeholder rather than lose the deploy.
35
+ - name: Ensure a report exists to deploy
36
+ env:
37
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
38
+ run: |
39
+ if [ -f apps/test-reports/dist/index.html ]; then
40
+ exit 0
41
+ fi
42
+ echo "::warning::Vitest wrote no HTML report; deploying a placeholder page."
43
+ mkdir -p apps/test-reports/dist
44
+ cat > apps/test-reports/dist/index.html <<HTML
45
+ <!doctype html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="utf-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1">
50
+ <title>Test report unavailable</title>
51
+ <style>
52
+ :root { color-scheme: light dark; }
53
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
54
+ font: 16px/1.6 system-ui, sans-serif; padding: 24px; }
55
+ main { max-width: 34rem; }
56
+ h1 { font-size: 1.25rem; margin: 0 0 .75rem; }
57
+ p { margin: 0 0 .75rem; opacity: .85; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <main>
62
+ <h1>Test report unavailable</h1>
63
+ <p>The last run of the test suite ended before the HTML reporter could
64
+ write a report, so there is nothing to show here yet.</p>
65
+ <p><a href="$RUN_URL">Open the workflow run</a> for the full output.</p>
66
+ <p>Commit $GITHUB_SHA</p>
67
+ </main>
68
+ </body>
69
+ </html>
70
+ HTML
71
+
72
+ - name: Deploy to Cloudflare Workers
73
+ working-directory: apps/test-reports
74
+ run: npx wrangler deploy
75
+ env:
76
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
77
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
78
+
79
+ - name: Upload report artifact
80
+ if: always()
81
+ uses: actions/upload-artifact@v4
82
+ with:
83
+ name: test-reports
84
+ path: apps/test-reports/dist/
85
+ if-no-files-found: ignore